Compare commits

...

51 Commits

Author SHA1 Message Date
hongjr03 008c8bcd09 feat(hub): add org sessions and usage admin APIs
List project agent sessions/runs and aggregate AgentRun cost and token
usage by project and optional folder for org admins.
2026-07-10 00:55:43 +08:00
hongjr03 683d5674d1 feat(hub): add org members, teams, and project team-access APIs
Manage memberships with last-OWNER protection, team lifecycle (archive
revokes TEAM grants), and TEAM→PROJECT access under org admin auth.
2026-07-10 00:55:34 +08:00
hongjr03 1f8510a47f feat(hub): add org project explorer admin APIs
Expose folder/project tree, create/move/rename/archive, and Feishu
binding archive under /api/org/:orgSlug for OWNER/ADMIN sessions.
2026-07-10 00:55:26 +08:00
hongjr03 c4f052efa2 feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole
guards, and the first org admin APIs (summary + project settings).
2026-07-10 00:55:19 +08:00
hongjr03 87e3d3f990 feat: let Feishu onboarding create projects in folders 2026-07-10 00:35:18 +08:00
hongjr03 519ea8144f feat: add Feishu project onboarding card 2026-07-10 00:32:09 +08:00
hongjr03 34e07e229f feat: add org admin project onboarding foundation 2026-07-10 00:25:00 +08:00
hongjr03 2b7aa3294f feat: add organization tenant model 2026-07-09 23:37:20 +08:00
hongjr03 5d315fff21 fix: enforce role tool whitelist 2026-07-09 20:48:03 +08:00
hongjr03 716101eed0 feat: add agent cost reporting 2026-07-09 20:29:56 +08:00
hongjr03 fc5a5365d8 fix: reply to Feishu trigger messages 2026-07-09 20:04:13 +08:00
hongjr03 fc0df7b823 fix: label Feishu trigger senders in agent prompts 2026-07-09 19:56:47 +08:00
hongjr03 f1d29176d3 feat: add slash command help 2026-07-09 19:43:13 +08:00
hongjr03 f260195439 fix: ack Feishu card actions before interrupt work 2026-07-09 19:26:57 +08:00
hongjr03 d01ea0e1cb feat: interrupt running agent via card button with confirm dialog
Add a  中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.

- builder.ts: render interrupt action (danger button + confirm) while the
  run is live; new `interrupted` flag renders a 已中断 footer instead of
  完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
  the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
  abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
  resolves the interrupt button, authorizes agent.cancel, aborts the run.
  Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
  lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
  (MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
  spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
  "谁能取消"). Default role remains MANAGE (Capability.normalCancel).

Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
2026-07-09 18:59:31 +08:00
hongjr03 d6724e5a83 feat: streaming agent card with tool-use trace, reasoning panel, and tool results
Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:

- Tool-use panel: collapsible, shows each tool call with status (running/
  success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
  on completion
- Answer text: rendered via native Feishu markdown component (no post-row
  round-trip)

Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)

New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)

client.ts: add sendCard/patchCard raw JSON primitives
2026-07-09 17:18:40 +08:00
hongjr03 346aee5a68 fix: bind agent sessions to role 2026-07-09 16:06:06 +08:00
hongjr03 491fd13ca8 fix: resolve Feishu sender names via basic profile API 2026-07-08 23:59:17 +08:00
hongjr03 79505e6d44 fix: use startedAt instead of createdAt in trigger integration test
AgentRun model has startedAt, not createdAt. Codex introduced this
typo when refactoring the trigger queue integration tests.
2026-07-08 23:30:28 +08:00
hongjr03 a0df8b6bcf fix: repair streaming output broken by markdown rendering changes
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):

1. Message type mismatch: sendTextMessage could create a text or post
   message, but patchTextMessage only works on interactive cards.
   Fix: add sendInteractiveCardMessage for streaming create (always
   interactive, always patchable).

2. flushTextToSink state corruption: the text management after flush
   had a flawed appendedDuringFlush heuristic that could lose text or
   duplicate it. Fix: properly track sent vs unsent text by removing
   the sent prefix length, keeping only the last chunk for future patches.

Also fix finish() to flush pending text regardless of currentMessageId.
2026-07-08 17:48:55 +08:00
hongjr03 36c0801e6f feat: add per-project message trigger wait queue
- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
2026-07-08 17:31:43 +08:00
hongjr03 4479f88f4f feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern
- resolveSenderName: API call with cache integration
- Trigger uses cache for approval card resolutions and audit metadata
- Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
2026-07-08 17:22:23 +08:00
hongjr03 2736006262 feat: add retry with exponential backoff for file send/download
- withRetry helper: configurable maxAttempts, baseDelayMs, shouldRetry
- sendFile: upload and message send wrapped with 3 retries, 1s/2s/4s backoff
- downloadMessageFile: wrapped with 3 retries
- Add unit tests (6) for retry behavior and sendFile retry integration
2026-07-08 17:15:30 +08:00
hongjr03 294d51dbfe feat: parse inbound post (rich text) messages
- parsePostMessage: full Feishu post payload parser with locale fallback,
  supports text/a/at/img/media/file/code_block/br/hr/emotion elements
- Trigger accepts post messages, extracts text + downloads attachments
- Post messages with attachments bypass batching, text-only posts batch
- Add unit tests (10) for all element types and edge cases
2026-07-08 17:05:51 +08:00
hongjr03 bef10149a9 feat: improve outbound message chunking with code-block-aware splitting
- Increase max message length from 4000 to 8000 chars
- Add splitAtBoundary: paragraph > newline > space priority, never split
  inside fenced code blocks (move split to before opening fence)
- Add sendLongText: sends multi-chunk long messages sequentially
- Update PatchableTextStream.flush to use splitAtBoundary
- Add unit tests (6) for splitting edge cases
2026-07-08 16:57:53 +08:00
hongjr03 4736610cf3 feat: add interactive approval/confirmation cards
- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
2026-07-08 16:47:31 +08:00
hongjr03 ad65d93b2e feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
  max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
  file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
2026-07-08 16:40:39 +08:00
hongjr03 6227e1931b feat: improve Feishu markdown rendering and processing status feedback
- Add buildOutboundPayload: detect markdown format, isolate code blocks,
  force plain text for tables, fallback from post to text on API rejection
- Add addReaction/removeReaction for processing status lifecycle
- Replace THUMBSUP with Typing→(remove on success | CrossMark on failure)
- Add unit tests for markdown rendering (15) and reactions (6)
2026-07-08 16:31:07 +08:00
hongjr03 a00e4f9871 fix(hub): 保留飞书回复上下文 2026-07-08 16:03:52 +08:00
hongjr03 a025d23af8 fix(hub): 启动前应用 Prisma 迁移 2026-07-08 15:45:12 +08:00
hongjr03 dfcc2d70f8 feat(hub): 支持教师团队权限 2026-07-08 15:42:53 +08:00
hongjr03 3f486232a8 feat(hub): 抽出运行时配置 2026-07-08 15:25:49 +08:00
hongjr03 a766d99573 fix(hub): 修复 Hub 部署脚本失败处理 2026-07-08 15:14:23 +08:00
hongjr03 1cfda3ccd2 ci(hub): 补齐 Hub 检查和健康 smoke 2026-07-08 15:14:06 +08:00
hongjr03 ead5cf04d6 fix(hub): 稳定 Feishu trigger 集成测试 2026-07-08 15:13:50 +08:00
hongjr03 ecbc2c8666 fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace,
denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留
(headless 无交互),沙箱是硬边界而非权限提示层。

install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正
OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY
(与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。

cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。
不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。

trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律
fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。
顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse
(FileMessageContentSchema / TextMessageContentSchema)。

ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放,
Open Questions 更新。
2026-07-08 13:11:14 +08:00
hongjr03 5b7fd70124 spec: 钉死 agent 执行面边界(ADR-0018)
ADR-0001/0002/0004 覆盖协作治理到 triggerAgent,但触发后 agent 在执行层
能干什么——读哪些文件、跑什么命令——无 ADR / spec 覆盖。ADR-0017 落地时
采用 bypassPermissions + 全量内置工具,agent 文件/shell 面对宿主无界;
workspace.ts 的 confine() 沙箱意图存在但被 ADR-0017 静默覆盖成死代码。

新增 ADR-0018 + Spec.System.AgentSurface 模块补这一层:
- AgentFileOp(run × path)+ Authorized 谓词(路径必须落在 run 工作区内)
- 与 Lock 正交:Lock 限定并发,Surface 限定波及面,都按 run × project 作用域
- 机制 OPEN(工具包装 / OS 沙箱 / SDK 钩子),契约只钉不变式
- ADR-0017 的 bypassPermissions + workspace.ts 死代码标为偏离契约,对齐为 follow-up
2026-07-08 12:37:37 +08:00
hongjr03 6178664696 fix(hub): send Feishu files explicitly 2026-07-08 11:48:25 +08:00
hongjr03 c73b41e1da fix(hub): resume Claude SDK sessions 2026-07-08 11:48:20 +08:00
hongjr03 012c8a7d89 fix(hub): default to OpenRouter Sonnet model 2026-07-08 11:48:16 +08:00
hongjr03 250f918346 chore: delete dead code 2026-07-08 02:37:44 +08:00
hongjr03 30d20421b3 fix(hub): busy 表情从 Hourglass 改为 OnIt(飞书支持的 emoji_type) 2026-07-08 02:22:05 +08:00
hongjr03 20bf7e271f fix(hub): 启动重置状态 + lazy-init 修复空白消息 + busy 改表情
三个修复:
1. 启动时清锁+标死run为FAILED(防重启后卡死)
2. 空白消息修复:lazy-init 改为第一个 text-delta 立即创建消息
  (不走 throttle);.then 加 fallback:没收到 text-delta 时用
  result.text 发完整回复
3. busy 提示从文本'项目正在处理中'改为表情回复

tsc rc=0。
2026-07-08 02:20:49 +08:00
hongjr03 eaa7fd4fd8 feat(hub): 文件收发 + 卡片回调 + 更好的流式
client.ts 重写:
- downloadMessageFile: 下载飞书消息里的文件/图片到本地
- sendFile: 上传文件到飞书 + 发送文件消息(msg_type=file)
- CardActionEvent 类型 + startFeishuListenerWithClient 加 onCardAction
  回调参数,注册 card.action.trigger 事件(消除了 'no card.action.trigger
  handle' 警告)
- 去掉未使用的 card builder 函数

trigger.ts:
- 处理 file/image 消息:下载到 workspace/.cph/inbox/,prompt 里告诉
  agent 文件路径
- 流式 throttle 从 800ms 降到 400ms,初始占位从 '…' 改为空(更干净)
- run 完成后扫描 workspace/build/ 下 5 分钟内的新文件,自动发送给
  老师(cph build 产出的 PDF 自动回传)

tsc rc=0。
2026-07-08 02:13:51 +08:00
hongjr03 1ad78dbcce test(hub): 适配 Claude Code SDK 的 mock 修复
helpers.ts: MockLanguageModel 加 doStream 实现(返回 text-delta +
finish part);runner.test.ts: 加 stubPrisma(必填字段)。
这些测试在 Claude Code SDK 迁移后还需要进一步适配,先提交 buffer。
2026-07-08 01:52:21 +08:00
hongjr03 085f7c7186 fix(hub): 去掉冗余 OPENROUTER_API_KEY,直接用 ANTHROPIC_AUTH_TOKEN 2026-07-08 01:48:01 +08:00
hongjr03 b9f50407be fix(hub): Claude Code SDK 经 OpenRouter Anthropic Skin,保留 provider-agnostic
我之前判断错了——OpenRouter 有 Anthropic Messages API 兼容端点
(https://openrouter.ai/api),Claude Code SDK 直连不需要代理。
ANTHROPIC_AUTH_TOKEN=OpenRouter key,ANTHROPIC_API_KEY='' 即可。

而且 ANTHROPIC_DEFAULT_SONNET_MODEL 可设 OpenRouter model ID
(z-ai/glm-4.7 等),所以 provider-agnostic 没丢。

ADR-0017 重写:从'Anthropic 专有'改为'Claude Code SDK via OpenRouter,
保留 provider-agnostic'。tsc rc=0。
2026-07-08 01:46:09 +08:00
hongjr03 082562ca08 docs(adr): ADR-0017 重写——从 provider-agnostic 改为 Claude Code SDK
放弃 provider-agnostic,用 @anthropic-ai/claude-agent-sdk。代价:不能走
OpenRouter(GLM 等),需 Anthropic API key。收益:compaction、工具审批、
partial message 流式、Claude-to-IM 原生兼容。迁移路径:如果将来需要
provider-agnostic,用 streamText + SSE 转换层(~100 行)替回 query()。
2026-07-08 01:40:28 +08:00
hongjr03 4bd5b03bb1 refactor(hub): Vercel AI SDK → Claude Code SDK
用 @anthropic-ai/claude-agent-sdk 的 query() 替换 Vercel AI SDK 的
streamText。SDK 自带 Read/Write/Bash/Glob/Grep 工具——read_file/
write_file/list_files/cph_check/cph_build 全部不需要了,Bash 跑
cph 直接。

UX 同时改成 workbuddy 风格:
- 收到 @bot → 表情回复(👍)确认收到,不发'已开始处理'
- agent 回复作为文本流式发送(text-as-card patch,markdown 渲染)
- 超长自动续发新消息,不截断
- 完成/出错不再额外发状态消息
- 去掉卡片(model选择器/取消按钮)

ADR-0017 更新:放弃 provider-agnostic,Claude Code SDK 是 Anthropic
专有。换来:compaction、工具审批、partial message 流式、成熟 agent
loop。

tsc rc=0。旧文件(workspace.ts/cph.ts/provider.ts/tools.ts)暂留,
下个 commit 清理。
2026-07-08 01:36:59 +08:00
hongjr03 1abcc495f4 test(hub): 真实 model 集成测试 + Gitea CI workflow
real-model.test.ts: 用真 OpenRouter model + 真 cph + 真 workspace 跑 agent
  tool-calls 循环,验证 MockProvider 测不到的路径(model 返回 tool_calls →
  dispatch → tool_result 回传 → 继续/stop)。没 OPENROUTER_API_KEY 时自动
  skip,不阻塞 CI;有 key 时自动跑。
  两个 test:(1) list files → cph check → 回答;(2) write file → read back。
hub-check.yml: Gitea CI workflow,对齐 checker-check/spec-check 的纪律:
  npm ci → prisma generate → tsc → prisma validate → 装 cph → vitest unit →
  vitest integration(postgres service container)→ real-model(可选,从
  secrets 注入 key)。
全 suite: 60 passed + 2 skipped(无 key)。tsc rc=0。
2026-07-07 23:45:59 +08:00
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史:
- 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆)
- 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation)
- runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages
- workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录
- runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange
- ToolContext 加 onFileChange sink 字段
- trigger.ts 接线 runId/sessionId/prisma 到 RunRequest

A-5 lock 心跳:
- ProjectAgentLock 加 heartbeatAt 列
- runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release)

60 tests 全过(tsc 干净)。
2026-07-07 22:20:30 +08:00
108 changed files with 16662 additions and 870 deletions
+132
View File
@@ -0,0 +1,132 @@
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 }}
+25
View File
@@ -0,0 +1,25 @@
# 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,6 +6,11 @@
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
@@ -26,4 +26,6 @@ The group can stay open while no Claude processing is active. A teacher leaving
- Multi-open is natural: a teacher participates in multiple project groups. - Multi-open is natural: a teacher participates in multiple project groups.
- Normal group discussion does not occupy the project. - Normal group discussion does not occupy the project.
- "Exit project" should not be the normal action in the group workflow. - "Exit project" should not be the normal action in the group workflow.
- Project rebinding or group archival needs explicit product rules, separate from run cancellation. - ADR-0021 adds those explicit product rules: active project↔Feishu chat binding
is strict 1:1, while archived historical bindings are retained so org admins
can correct pilot setup mistakes without losing audit/session history.
@@ -1,4 +1,4 @@
# ADR 0017: AgentSession Is Provider-Bound # ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
## Status ## Status
@@ -6,45 +6,62 @@ Accepted.
## Context ## Context
ADR-0002 says a long-lived `AgentSession` can be reused by many runs, but never The original ADR-0017 mandated a provider-agnostic agent layer: any
addressed the provider/model dimension. The agent layer is provider-agnostic OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
(ADR-0001..0003 never required Claude specifically; the `@Claude` trigger name SDK's `streamText`. The motivation was to avoid vendor lock-in.
is a product brand, not a provider commitment). In practice the Hub routes
model calls through an OpenAI-compatible client (e.g. OpenRouter), so a run may
target different models — and the motivation for multi-model is **role-based
routing**: drafting, reviewing, and triage suit different models.
That raises the question ADR-0002 left open: when a teacher switches model In practice, the hand-rolled agent loop lacked capabilities that a production
mid-project, does the `AgentSession` carry live context across the switch, or agent needs: context compaction (long conversations), tool approval flows,
is it bound to a single provider/model? 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 ## Decision
An `AgentSession` is **provider/model-bound**. A model or provider switch Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
starts a new `AgentSession`. The `AgentSession` does not carry live context OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
across a switch.
Cross-session continuity is not the session's job — it is carried by ADR-0003's OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
project memory and anchors, which the Hub reads on demand when seeding a new SDK speaks its native protocol directly to OpenRouter — no proxy, no format
run. This keeps B consistent with ADR-0003 ("the Hub avoids becoming a full conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
chat history store"): session continuity and history continuity are the same streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
mechanism, both via memory/anchors, never via a live cross-provider session. tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
Per-run model selection (the run carries its model) is the switching point. 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 ## Consequences
- Switching model mid-project = new session; the new run seeds from project - Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
memory/anchors (ADR-0003), not the prior session's live context. Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
- The agent layer is provider-agnostic: model calls go through an Agent SDK-bound.
OpenAI-compatible client, and the tool-calling loop is delegated to the - The agent loop is Claude Code SDK's (compaction, tool approval, partial
Vercel AI SDK (`generateText` + `tool`). The provider seam is the SDK's message streaming) — not hand-rolled.
`LanguageModel` interface, not a hand-rolled loop; swapping provider is - Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
swapping the `createOpenAICompatible` factory, not rewriting the loop. No the SDK's built-in Read/Write/Bash/Glob/Grep.
hard dependency on a single-vendor agent SDK. - The SSE event format matches Claude-to-IM's expected protocol natively.
- Role-based model routing (different run kinds default to different models) is - Per-run model selection works via role→model mapping; models are OpenRouter
a product/admin configuration concern, not a spec invariant. IDs, not Anthropic-only aliases.
- "AgentSession reused by many runs" (ADR-0002) holds *within* one - OpenRouter recommends setting Anthropic as the top-priority provider for
provider/model; it does not span a switch. Claude Code; non-Anthropic models may have reduced compatibility with some
- The `@Claude` trigger name remains the product mention brand regardless of the SDK features (e.g. thinking blocks).
backing model.
@@ -0,0 +1,166 @@
# 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.
@@ -0,0 +1,129 @@
# 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.
@@ -0,0 +1,92 @@
# 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.
@@ -0,0 +1,102 @@
# ADR 0021: Org Admin Project Onboarding
## Status
Accepted.
## Context
ADR-0020 introduced `Organization` as the SaaS tenant root. The next product
surface is not only the Feishu bot trigger path, but also the control planes
around it:
- platform staff need a private platform admin area to create and operate orgs;
- customer org owners/admins need an org admin area for teams, roles, model
provider configuration, projects, folders, sessions, and usage accounting;
- ordinary teachers should be able to start work from the natural Feishu group
flow without entering the web backend.
Without a crisp project onboarding model, project creation, Feishu chat binding,
permissions, session history, and usage accounting would drift into separate
ad-hoc rules.
## Decision
Use one web app with two separate admin areas:
```text
/admin/platform internal platform admins only
/admin/org/:orgSlug customer org OWNER/ADMIN only
```
The guards are intentionally separate:
- `requirePlatformAdmin` for internal operators;
- `requireOrgRole` for org owner/admin backend access;
- `requireProjectPermission` for project-level actions.
Platform admin is not an `OrganizationMembership` and is not represented by
project `PermissionGrant`. Platform admin audit remains separate from customer
project audit.
Customer orgs are manually created by platform staff for the pilot. Platform
admins invite/allowlist other platform admins. Customer users authenticate with
the customer's Feishu app. The same customer-owned Feishu app may be used for
OAuth login, bot messages, and directory sync. App secrets are org-scoped
secrets and must be encrypted at rest; business code should access them through
a secret/connection resolver, not raw plaintext columns.
Project management has two creation paths:
- org owner/admin creates projects in the org web backend;
- ordinary org members may create a project from an unbound Feishu group when
`membersCanCreateProjects` is enabled for the org.
In both paths the creator gets project `MANAGE`. A Feishu-created project is
immediately bound to the source chat, and that chat receives project `EDIT`.
Feishu chat binding is strict 1:1:
- one Feishu chat binds to at most one active project;
- one project binds to at most one Feishu chat;
- binding an existing unbound project from Feishu requires the clicking user to
have `MANAGE` on that project;
- binding creates an active `FEISHU_CHAT -> PROJECT EDIT` grant;
- binding mistakes are corrected by org owner/admin unbinding or archiving the
binding in the backend; historical sessions stay on their original project.
Folders are transparent organization nodes for project navigation and usage
aggregation:
- folders belong to one organization;
- projects may sit in folders;
- folders can be nested;
- folders are not permission resources;
- folder visibility, team policies, and inherited grants are deferred.
Project permissions remain on `PROJECT` resources. Moving a project between
folders does not change grants.
Usage accounting is org-wise and project/folder aggregatable. It is not payment
collection in the pilot because customers supply their own model provider API
keys and base URLs.
## Consequences
- `OrganizationProjectSettings.membersCanCreateProjects` gates Feishu group
project creation for ordinary members.
- `Folder` and `Project.folderId` support the file-manager-like project
explorer without creating a second ACL system.
- Service code should expose project creation and chat binding as reusable
backend operations so Feishu cards and future web APIs call the same rules.
- Org role/model/provider/billing panels can be added on top of the org tenant
root without changing project authorization.
## Open Questions / Deferred
- True one-click Feishu app provisioning is deferred; pilot uses guided setup
and readiness checks.
- Folder-level permissions are deferred until there is a concrete customer need.
- Self-serve org signup and payment collection are deferred beyond pilot.
- The exact platform admin identity store and audit schema are separate from
this ADR and should be modeled before exposing the platform admin panel.
+1
View File
@@ -0,0 +1 @@
Hello, World!
+40 -12
View File
@@ -1,23 +1,51 @@
# Hub runtime configuration. Copy to .env and fill in. # Hub runtime configuration. Copy to .env and fill in.
# PostgreSQL connection string. Used by Prisma and the Hub server. # PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm" DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# OpenRouter (or any OpenAI-compatible) API key + base URL. # Claude Code SDK auth via OpenRouter's Anthropic Skin.
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the # ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
# OpenAI-compatible client at OpenRouter by default. # ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
OPENROUTER_API_KEY="" ANTHROPIC_BASE_URL="https://openrouter.ai/api"
# Optional: override the base URL (e.g. direct vendor API, local gateway). ANTHROPIC_AUTH_TOKEN=""
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1" ANTHROPIC_API_KEY=""
# Hub server port. Defaults to 8788. # Model override (optional). Defaults to anthropic/claude-sonnet-5.
PORT=8788 # 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"
# Feishu (lark) bot credentials. The bot receives @mentions in project groups. # 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_ID=""
FEISHU_APP_SECRET="" FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID="" FEISHU_BOT_OPEN_ID=""
# Path to the `cph` binary (the Courseware-side checker, ADR-0016). # Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
# Defaults to `cph` on PATH if unset.
# CPH_BIN="/usr/local/bin/cph" # 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"
+8
View File
@@ -16,6 +16,14 @@ RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs. # Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+25 -11
View File
@@ -7,12 +7,11 @@
# PLATFORM_DEPLOY_USER optional, defaults to deploy # PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22 # PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # 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, and a # The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has # (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
# passwordless sudo for: # Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# systemctl restart cph-hub.service # writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
# systemctl is-active --quiet cph-hub.service
set -euo pipefail set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}" HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
@@ -20,12 +19,15 @@ SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}" DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
PORT="${PLATFORM_DEPLOY_PORT:-22}" PORT="${PLATFORM_DEPLOY_PORT:-22}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" 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" HUB_DIR="$BASE/hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new) 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). # 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
rsync -az --delete \ rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \ --exclude node_modules --exclude dist --exclude .env \
@@ -37,11 +39,23 @@ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run bu
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true set -euo pipefail
sudo systemctl restart cph-hub.service if [ \"\$(id -u)\" = \"0\" ]; then
sleep 2 BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; } systemctl restart cph-hub.service
curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; } 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)" echo "Deployed cph-hub to $HOST ($HUB_DIR)"
+18 -4
View File
@@ -11,11 +11,12 @@ set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}" BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}" HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-root}" SERVICE_USER="${SERVICE_USER:-cph-hub}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}" PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}" NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -z "$NODE_BIN" ]; then if [ -z "$NODE_BIN" ]; then
echo "node must be on PATH, or set NODE_BIN." >&2 echo "node must be on PATH, or set NODE_BIN." >&2
@@ -29,6 +30,10 @@ if [ ! -f "$HUB_DIR/dist/server.js" ]; then
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2 echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
exit 1 exit 1
fi 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. # Seed the env file if absent. Secrets stay on the server, never in the repo.
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
@@ -36,25 +41,34 @@ if [ ! -f "$ENV_FILE" ]; then
cat >"$ENV_FILE" <<EOF cat >"$ENV_FILE" <<EOF
NODE_ENV=production NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
OPENROUTER_API_KEY= # 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_ID=
FEISHU_APP_SECRET= FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID= FEISHU_BOT_OPEN_ID=
PORT=$PORT PORT=$PORT
HOST=$HOST HOST=$HOST
EOF EOF
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run." chmod 0600 "$ENV_FILE"
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
exit 0 exit 0
fi fi
# Render the unit with concrete paths. # Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service UNIT=/etc/systemd/system/cph-hub.service
TMP_UNIT="$(mktemp)"
sed \ sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT" "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -m 0644 "$TMP_UNIT" "$UNIT"
rm -f "$TMP_UNIT"
systemctl daemon-reload systemctl daemon-reload
systemctl enable cph-hub.service systemctl enable cph-hub.service
+1172 -17
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,7 +7,7 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai-compatible": "^3.0.5", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1", "@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
@@ -22,11 +22,11 @@
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).", "description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": { "scripts": {
"dev": "tsx watch src/server.ts", "dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/server.js", "start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit", "check": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "prisma generate --schema prisma/schema.prisma", "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:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
@@ -0,0 +1,45 @@
-- 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);
@@ -0,0 +1,143 @@
-- 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;
@@ -0,0 +1,9 @@
-- 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";
@@ -0,0 +1,6 @@
-- 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");
@@ -0,0 +1,145 @@
-- 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;
@@ -0,0 +1,92 @@
-- ADR-0021: org admin project onboarding.
-- Folder is a transparent project explorer node; project remains the permission boundary.
CREATE TABLE "OrganizationProjectSettings" (
"organizationId" TEXT NOT NULL,
"membersCanCreateProjects" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationProjectSettings_pkey" PRIMARY KEY ("organizationId")
);
ALTER TABLE "OrganizationProjectSettings"
ADD CONSTRAINT "OrganizationProjectSettings_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "OrganizationProjectSettings" ("organizationId", "membersCanCreateProjects", "createdAt", "updatedAt")
SELECT "id", true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization"
ON CONFLICT ("organizationId") DO NOTHING;
CREATE TABLE "Folder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"sortKey" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Folder"
ADD CONSTRAINT "Folder_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Folder"
ADD CONSTRAINT "Folder_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "Folder_organizationId_parentId_archivedAt_idx"
ON "Folder"("organizationId", "parentId", "archivedAt");
CREATE INDEX "Folder_organizationId_parentId_sortKey_idx"
ON "Folder"("organizationId", "parentId", "sortKey");
CREATE UNIQUE INDEX "Folder_active_sibling_name_key"
ON "Folder"("organizationId", COALESCE("parentId", ''), lower("name"))
WHERE "archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "sortKey", "createdAt", "updatedAt")
SELECT 'folder_inbox_' || md5("id"), "id", NULL, 'Inbox', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization";
ALTER TABLE "Project" ADD COLUMN "folderId" TEXT;
UPDATE "Project" AS p
SET "folderId" = f."id"
FROM "Folder" AS f
WHERE f."organizationId" = p."organizationId"
AND f."parentId" IS NULL
AND f."name" = 'Inbox'
AND p."folderId" IS NULL;
ALTER TABLE "Project"
ADD CONSTRAINT "Project_folderId_fkey"
FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Project_folderId_archivedAt_idx"
ON "Project"("folderId", "archivedAt");
ALTER TABLE "ProjectGroupBinding" ADD COLUMN "archivedAt" TIMESTAMP(3);
DROP INDEX IF EXISTS "ProjectGroupBinding_projectId_key";
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_key";
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_idx";
CREATE INDEX "ProjectGroupBinding_projectId_archivedAt_idx"
ON "ProjectGroupBinding"("projectId", "archivedAt");
CREATE INDEX "ProjectGroupBinding_chatId_archivedAt_idx"
ON "ProjectGroupBinding"("chatId", "archivedAt");
CREATE UNIQUE INDEX "ProjectGroupBinding_active_project_key"
ON "ProjectGroupBinding"("projectId")
WHERE "archivedAt" IS NULL;
CREATE UNIQUE INDEX "ProjectGroupBinding_active_chat_key"
ON "ProjectGroupBinding"("chatId")
WHERE "archivedAt" IS NULL;
+325 -77
View File
@@ -3,9 +3,9 @@
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the // Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate: // legacy teaching-material-host-service schema, each deliberate:
// //
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model- // - AgentSession is provider/model-bound. Provider runtime cursors such as
// bound, not Claude-bound; `provider`+`model` replace it. Switching model // Claude SDK `session_id` live in `metadata`, so switching model still means
// = new session, so the unique constraint is on the session id alone. // a new Hub session while same-session runs can resume provider context.
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice), // - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
// distinct from platform UserRole (admin/teacher) — legacy conflated them. // distinct from platform UserRole (admin/teacher) — legacy conflated them.
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed // - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
@@ -25,6 +25,67 @@ datasource db {
// --- Platform identity --------------------------------------------------- // --- 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). /// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -34,14 +95,17 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[] platformRoles PlatformRoleAssignment[]
createdProjects Project[] @relation("projectCreator") organizationMemberships OrganizationMembership[]
requestedRuns AgentRun[] @relation("runRequester") createdProjects Project[] @relation("projectCreator")
heldLocks ProjectAgentLock[] @relation("lockHolder") requestedRuns AgentRun[] @relation("runRequester")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator") heldLocks ProjectAgentLock[] @relation("lockHolder")
permissionGrants PermissionGrant[] @relation("grantCreator") feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") teamMemberships TeamMembership[]
auditEntries AuditEntry[] @relation("auditActor") externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
} }
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole. /// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
@@ -64,10 +128,139 @@ enum PlatformRole {
TEACHER 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) --------------------------------- // --- Project & Feishu binding (ADR-0001) ---------------------------------
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
model Folder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
children Folder[] @relation("folderTree")
projects Project[]
@@index([organizationId, parentId, archivedAt])
@@index([organizationId, parentId, sortKey])
}
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String
folderId String?
name String name String
workspaceDir String workspaceDir String
createdByUserId String? createdByUserId String?
@@ -75,47 +268,52 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
groupBinding ProjectGroupBinding? folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
agentSessions AgentSession[] createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
agentRuns AgentRun[] groupBindings ProjectGroupBinding[]
agentLock ProjectAgentLock? agentSessions AgentSession[]
permissionGrants PermissionGrant[] @relation("projectGrants") agentRuns AgentRun[]
permissionSettings PermissionSettings[] @relation("projectSettings") agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
} }
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒ /// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat). /// (1:1). Historical archived bindings are retained for audit; partial unique
/// The "at most one binding per project" half is enforced by the 1:1 relation. /// indexes in migrations enforce one active binding per project and per chat.
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
/// here; archival is a future policy, not a current column.
model ProjectGroupBinding { model ProjectGroupBinding {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @unique projectId String
chatId String @unique chatId String
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([chatId]) @@index([projectId, archivedAt])
@@index([chatId, archivedAt])
} }
// --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- // --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider` /// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs /// `model` capture the binding; provider-specific runtime cursors live in
/// (ADR-0002); a switch ⇒ new session. /// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
model AgentSession { model AgentSession {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
provider String provider String
roleId String
model String model String
title String? title String?
metadata Json metadata Json
@@ -123,11 +321,13 @@ model AgentSession {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[] runs AgentRun[]
messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt]) @@index([projectId, archivedAt])
@@index([provider, model]) @@index([provider, roleId, model])
@@index([projectId, provider, roleId, model, archivedAt])
@@index([updatedAt]) @@index([updatedAt])
} }
@@ -150,30 +350,37 @@ enum AgentEntrypoint {
} }
model AgentRun { model AgentRun {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
sessionId String? sessionId String?
requestedByUserId String? requestedByUserId String?
entrypoint AgentEntrypoint entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE) status AgentRunStatus @default(ACTIVE)
prompt String prompt String
model String model String
provider String provider String
summary String? summary String?
inputTokens Int? inputTokens Int?
outputTokens 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 metadata Json
error String? error String?
startedAt DateTime @default(now()) startedAt DateTime @default(now())
finishedAt DateTime? finishedAt DateTime?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull) session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull) requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock? projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@index([projectId, status]) @@index([projectId, status])
@@index([projectId, finishedAt])
@@index([sessionId]) @@index([sessionId])
@@index([requestedByUserId]) @@index([requestedByUserId])
@@index([updatedAt]) @@index([updatedAt])
@@ -184,15 +391,16 @@ model AgentRun {
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an /// 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. /// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock { model ProjectAgentLock {
projectId String @id projectId String @id
runId String @unique runId String @unique
holderUserId String? holderUserId String?
acquiredAt DateTime @default(now()) acquiredAt DateTime @default(now())
expiresAt DateTime? heartbeatAt DateTime?
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade) run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull) holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
@@index([expiresAt]) @@index([expiresAt])
} }
@@ -218,34 +426,31 @@ enum PermissionResourceType {
} }
/// ADR-0004 PermissionGrant: resource × principal × role. /// ADR-0004 PermissionGrant: resource × principal × role.
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004). /// ADR-0019 replaces the old opaque `principal` with typed
/// The compound unique covers "one active grant per (resource, principal, role)" /// `principalType/principalId` so user/team/Feishu principals compose through
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new /// the same authorization path.
/// row, not a conflict.
model PermissionGrant { model PermissionGrant {
id String @id @default(cuid()) id String @id @default(cuid())
resourceType PermissionResourceType resourceType PermissionResourceType
resourceId String resourceId String
principal String principalType PrincipalType
principalId String
role PermissionRole role PermissionRole
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
revokedAt DateTime? revokedAt DateTime?
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade) createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([resourceType, resourceId, principal, role, revokedAt])
@@index([resourceType, resourceId, revokedAt]) @@index([resourceType, resourceId, revokedAt])
@@index([principal, revokedAt]) @@index([principalType, principalId, revokedAt])
} }
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one /// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
/// opaque string column each; the enum/value-domain is decided by admin policy, /// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
/// not by this schema. `resourceType`+`resourceId` identify the resource. /// authorizer: ROLE, MANAGE_ONLY, DISABLED.
/// Related to Project only when resourceType=PROJECT (null otherwise).
model PermissionSettings { model PermissionSettings {
id String @id @default(cuid()) id String @id @default(cuid())
resourceType PermissionResourceType resourceType PermissionResourceType
resourceId String resourceId String
externalShare String externalShare String
@@ -254,41 +459,38 @@ model PermissionSettings {
collaboratorMgmt String collaboratorMgmt String
agentTrigger String agentTrigger String
agentCancel String agentCancel String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
@@unique([resourceType, resourceId]) @@unique([resourceType, resourceId])
@@index([resourceType, resourceId]) @@index([resourceType, resourceId])
} }
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant: /// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability, /// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
/// edit+ role); this table decides "can trigger *which* agent role" (e.g. /// edit+ role); this table decides "can trigger *which* agent role" (e.g.
/// /review vs /draft). Two gates in series — both must pass. /// /review vs /draft). Two gates in series — both must pass.
/// ///
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are /// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal /// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
/// (sub-typology OPEN). A project-scoped row grants the role on that project; /// row grants the role on that project;
/// the compound unique covers "one active grant per (project, principal, role)". /// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant. /// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant { model RoleTriggerGrant {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
roleId String roleId String
principal String principalType PrincipalType
principalId String
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
revokedAt DateTime? revokedAt DateTime?
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade) project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([projectId, roleId, principal, revokedAt])
@@index([projectId, roleId, revokedAt]) @@index([projectId, roleId, revokedAt])
@@index([principal, revokedAt]) @@index([principalType, principalId, revokedAt])
} }
// --- Audit (ADR Audit, content OPEN) ------------------------------------- // --- Audit (ADR Audit, content OPEN) -------------------------------------
@@ -331,3 +533,49 @@ model FeishuEventReceipt {
@@index([messageId]) @@index([messageId])
@@index([receivedAt]) @@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
@@ -0,0 +1,124 @@
/**
* Feishu web OAuth (user login) for org admin panel.
*
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot).
* Per-org encrypted Feishu app secrets are deferred (ADR-0021).
*
* Flow:
* 1. GET authorize URL → user consents
* 2. callback code → user_access_token (authen/v2/oauth/token)
* 3. user_access_token → user info (authen/v1/user_info) → open_id
*/
export interface FeishuOAuthConfig {
readonly appId: string;
readonly appSecret: string;
readonly redirectUri: string;
/** Space-separated scopes requested at authorize time. */
readonly scope: string;
readonly authorizeBaseUrl?: string;
readonly tokenUrl?: string;
readonly userInfoUrl?: string;
readonly fetchImpl?: typeof fetch;
}
export interface FeishuOAuthUser {
readonly openId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
}
const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize";
const DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
/** Minimal scopes for open_id + name/avatar on user_info. */
export const DEFAULT_OAUTH_SCOPE = "contact:user.base:readonly";
export function buildAuthorizeUrl(config: FeishuOAuthConfig, state: string): string {
const base = config.authorizeBaseUrl ?? DEFAULT_AUTHORIZE_BASE;
const url = new URL(base);
url.searchParams.set("client_id", config.appId);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", config.redirectUri);
url.searchParams.set("state", state);
if (config.scope.trim() !== "") {
url.searchParams.set("scope", config.scope);
}
return url.toString();
}
export async function exchangeCodeForUser(
config: FeishuOAuthConfig,
code: string,
): Promise<FeishuOAuthUser> {
const accessToken = await exchangeCodeForAccessToken(config, code);
return fetchUserInfo(config, accessToken);
}
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
const fetchImpl = config.fetchImpl ?? fetch;
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
const res = await fetchImpl(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: config.appId,
client_secret: config.appSecret,
code,
redirect_uri: config.redirectUri,
}),
});
const body = (await res.json()) as {
code?: number;
access_token?: string;
error?: string;
error_description?: string;
msg?: string;
};
if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") {
const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`;
throw new Error(`Feishu OAuth token exchange failed: ${detail}`);
}
return body.access_token;
}
async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> {
const fetchImpl = config.fetchImpl ?? fetch;
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
const res = await fetchImpl(userInfoUrl, {
method: "GET",
headers: { Authorization: `Bearer ${userAccessToken}` },
});
const body = (await res.json()) as {
code?: number;
msg?: string;
data?: {
open_id?: string;
name?: string;
en_name?: string;
avatar_url?: string;
avatar_middle?: string;
};
};
if (!res.ok || body.code !== 0 || body.data === undefined) {
throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`);
}
const openId = body.data.open_id;
if (typeof openId !== "string" || openId === "") {
throw new Error("Feishu user_info response missing open_id");
}
const displayName =
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ??
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ??
openId;
const avatar =
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== ""
? body.data.avatar_url
: null) ??
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
? body.data.avatar_middle
: null);
return { openId, displayName, avatarUrl: avatar };
}
+137
View File
@@ -0,0 +1,137 @@
/**
* HTTP guards for org admin APIs (ADR-0021).
*
* Platform admin is a separate control plane — not modeled here.
*/
import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client";
import type { FastifyReply, FastifyRequest } from "fastify";
import {
SESSION_COOKIE_NAME,
verifySession,
type SessionPayload,
} from "./session.js";
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
export interface AuthContext {
readonly session: SessionPayload;
readonly user: User;
}
export interface OrgAuthContext extends AuthContext {
readonly organization: Organization;
readonly membershipRole: OrganizationMemberRole;
}
export interface GuardDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
}
export class HttpError extends Error {
readonly statusCode: number;
readonly code: string;
constructor(statusCode: number, code: string, message: string) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
this.code = code;
}
}
export async function requireSession(
request: FastifyRequest,
reply: FastifyReply,
deps: GuardDeps,
): Promise<AuthContext | null> {
const raw = request.cookies[SESSION_COOKIE_NAME];
if (raw === undefined || raw === "") {
await sendError(reply, 401, "unauthenticated", "login required");
return null;
}
const session = verifySession(raw, deps.sessionSecret);
if (session === null) {
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
return null;
}
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
await sendError(reply, 401, "unauthenticated", "session user not found");
return null;
}
return { session, user };
}
export async function requireOrgRole(
request: FastifyRequest,
reply: FastifyReply,
deps: GuardDeps,
options: {
readonly orgSlug: string;
readonly roles?: readonly OrganizationMemberRole[];
},
): Promise<OrgAuthContext | null> {
const auth = await requireSession(request, reply, deps);
if (auth === null) return null;
const allowed = options.roles ?? ORG_ADMIN_ROLES;
const organization = await deps.prisma.organization.findUnique({
where: { slug: options.orgSlug },
});
if (organization === null) {
await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`);
return null;
}
if (organization.status !== "ACTIVE") {
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
return null;
}
const membership = await deps.prisma.organizationMembership.findFirst({
where: {
organizationId: organization.id,
userId: auth.user.id,
revokedAt: null,
},
select: { role: true },
});
if (membership === null) {
await sendError(reply, 403, "forbidden", "not a member of this organization");
return null;
}
if (!allowed.includes(membership.role)) {
await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`);
return null;
}
return {
...auth,
organization,
membershipRole: membership.role,
};
}
export async function requireOrgProject(
deps: GuardDeps,
organizationId: string,
projectId: string,
): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> {
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true, name: true, archivedAt: true },
});
if (project === null || project.organizationId !== organizationId) {
throw new HttpError(404, "project_not_found", `project not found: ${projectId}`);
}
return project;
}
export async function sendError(
reply: FastifyReply,
statusCode: number,
code: string,
message: string,
): Promise<void> {
await reply.status(statusCode).send({ error: { code, message } });
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Signed cookie session for org admin web login (ADR-0021).
*
* Payload is HMAC-SHA256 signed (HUB_SESSION_SECRET). No server-side session
* table in v1 — logout clears the cookie; stolen-cookie revoke is deferred.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
export const SESSION_COOKIE_NAME = "cph_session";
export const OAUTH_STATE_COOKIE_NAME = "cph_oauth_state";
/** Default session TTL: 7 days. */
export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
/** OAuth state cookie TTL: 10 minutes. */
export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
export interface SessionPayload {
readonly userId: string;
readonly feishuOpenId: string;
readonly iat: number;
readonly exp: number;
}
export interface OAuthStatePayload {
readonly nonce: string;
readonly returnTo: string;
readonly exp: number;
}
export function signSession(
payload: Omit<SessionPayload, "iat" | "exp">,
secret: string,
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: SessionPayload = {
userId: payload.userId,
feishuOpenId: payload.feishuOpenId,
iat: nowSeconds,
exp: nowSeconds + ttlSeconds,
};
return signJson(full, secret);
}
export function verifySession(
token: string,
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): SessionPayload | null {
const payload = verifyJson<SessionPayload>(token, secret);
if (payload === null) return null;
if (typeof payload.userId !== "string" || payload.userId === "") return null;
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
if (payload.exp <= nowSeconds) return null;
return payload;
}
export function signOAuthState(
payload: Omit<OAuthStatePayload, "exp">,
secret: string,
ttlSeconds: number = OAUTH_STATE_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: OAuthStatePayload = {
nonce: payload.nonce,
returnTo: payload.returnTo,
exp: nowSeconds + ttlSeconds,
};
return signJson(full, secret);
}
export function verifyOAuthState(
token: string,
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): OAuthStatePayload | null {
const payload = verifyJson<OAuthStatePayload>(token, secret);
if (payload === null) return null;
if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
if (typeof payload.returnTo !== "string") return null;
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
return payload;
}
function signJson(value: unknown, secret: string): string {
const body = Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
const sig = hmac(body, secret);
return `${body}.${sig}`;
}
function verifyJson<T>(token: string, secret: string): T | null {
const dot = token.indexOf(".");
if (dot <= 0 || dot === token.length - 1) return null;
const body = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = hmac(body, secret);
if (!safeEqual(sig, expected)) return null;
try {
return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T;
} catch {
return null;
}
}
function hmac(body: string, secret: string): string {
return createHmac("sha256", secret).update(body).digest("base64url");
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Map domain / HTTP errors to consistent JSON responses.
*/
import type { FastifyReply } from "fastify";
import { HttpError, sendError } from "./auth/guards.js";
export async function handleRouteError(reply: FastifyReply, err: unknown): Promise<void> {
if (err instanceof HttpError) {
await sendError(reply, err.statusCode, err.code, err.message);
return;
}
if (err instanceof Error) {
const mapped = mapDomainError(err.message);
if (mapped !== null) {
await sendError(reply, mapped.statusCode, mapped.code, mapped.message);
return;
}
await sendError(reply, 500, "internal_error", "internal error");
return;
}
await sendError(reply, 500, "internal_error", "internal error");
}
function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null {
const lower = message.toLowerCase();
if (lower.includes("not found")) {
return { statusCode: 404, code: "not_found", message };
}
if (
lower.includes("requires") ||
lower.includes("forbidden") ||
lower.includes("cannot") ||
lower.includes("not an active member") ||
lower.includes("refused")
) {
return { statusCode: 403, code: "forbidden", message };
}
if (
lower.includes("is required") ||
lower.includes("already") ||
lower.includes("invalid") ||
lower.includes("accepts only")
) {
return { statusCode: 400, code: "bad_request", message };
}
return null;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Registers org-admin HTTP surface: auth, org APIs, (later) static SPA.
*/
import cookie from "@fastify/cookie";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { registerAuthRoutes } from "./routes/authRoutes.js";
import { registerOrgRoutes } from "./routes/orgRoutes.js";
export interface AdminPluginConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly publicBaseUrl: string;
readonly feishuAppId: string;
readonly feishuAppSecret: string;
readonly projectWorkspaceRoot: string;
readonly cookieSecure?: boolean;
readonly oauthScope?: string;
readonly fetchImpl?: typeof fetch;
}
export async function registerAdminPlugin(
app: FastifyInstance,
config: AdminPluginConfig,
): Promise<void> {
await app.register(cookie);
const cookieSecure =
config.cookieSecure ?? config.publicBaseUrl.startsWith("https://");
await registerAuthRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
publicBaseUrl: config.publicBaseUrl,
feishuAppId: config.feishuAppId,
feishuAppSecret: config.feishuAppSecret,
cookieSecure,
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
});
await registerOrgRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
projectWorkspaceRoot: config.projectWorkspaceRoot,
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { PermissionRole, PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
grantTeamProjectAccess,
listProjectTeamAccess,
revokeTeamProjectAccess,
} from "../../permissions/projectTeamAccess.js";
import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
export async function registerAccessRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
return { access: await listProjectTeamAccess(config.prisma, projectId) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const body = request.body as {
teamId?: unknown;
teamSlug?: unknown;
role?: unknown;
};
const role = parseRole(body.role);
if (role === null) {
return reply.status(400).send({
error: { code: "bad_request", message: "role must be READ|EDIT|MANAGE" },
});
}
const entry = await grantTeamProjectAccess(config.prisma, {
projectId,
role,
createdByUserId: auth.user.id,
...(typeof body.teamId === "string" ? { teamId: body.teamId } : {}),
...(typeof body.teamSlug === "string" ? { teamSlug: body.teamSlug } : {}),
});
return entry;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete(
"/api/org/:orgSlug/projects/:projectId/team-access/:teamId",
async (request, reply) => {
try {
const { orgSlug, projectId, teamId } = request.params as {
orgSlug: string;
projectId: string;
teamId: string;
};
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
return { revoked: count };
} catch (err) {
return handleRouteError(reply, err);
}
},
);
}
function parseRole(value: unknown): PermissionRole | null {
if (typeof value !== "string") return null;
return (ROLES as readonly string[]).includes(value) ? (value as PermissionRole) : null;
}
+293
View File
@@ -0,0 +1,293 @@
/**
* Feishu OAuth + session routes for org admin web login.
*/
import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import {
buildAuthorizeUrl,
DEFAULT_OAUTH_SCOPE,
exchangeCodeForUser,
type FeishuOAuthConfig,
} from "../auth/feishuOAuth.js";
import {
HttpError,
requireSession,
type GuardDeps,
} from "../auth/guards.js";
import {
OAUTH_STATE_COOKIE_NAME,
SESSION_COOKIE_NAME,
signOAuthState,
signSession,
verifyOAuthState,
} from "../auth/session.js";
import { handleRouteError } from "../errors.js";
export interface AuthRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly publicBaseUrl: string;
readonly feishuAppId: string;
readonly feishuAppSecret: string;
readonly oauthScope?: string;
readonly cookieSecure: boolean;
readonly fetchImpl?: typeof fetch;
}
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
const oauthConfig: FeishuOAuthConfig = {
appId: config.feishuAppId,
appSecret: config.feishuAppSecret,
redirectUri,
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
};
app.get("/auth/feishu", async (request, reply) => {
try {
const returnTo = sanitizeReturnTo(
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
? String((request.query as { returnTo?: string }).returnTo ?? "")
: "",
);
const nonce = randomBytes(16).toString("hex");
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit.
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
maxAge: 600,
});
const url = buildAuthorizeUrl(oauthConfig, stateToken);
return reply.redirect(url);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/auth/feishu/callback", async (request, reply) => {
try {
const query = request.query as {
code?: string;
state?: string;
error?: string;
};
if (typeof query.error === "string" && query.error !== "") {
return reply.redirect(`/admin/login?error=${encodeURIComponent(query.error)}`);
}
const code = query.code;
const state = query.state;
if (typeof code !== "string" || code === "" || typeof state !== "string" || state === "") {
throw new HttpError(400, "bad_request", "missing OAuth code or state");
}
const statePayload = verifyOAuthState(state, config.sessionSecret);
if (statePayload === null) {
throw new HttpError(400, "bad_request", "invalid or expired OAuth state");
}
const cookieNonce = request.cookies[OAUTH_STATE_COOKIE_NAME];
if (cookieNonce === undefined || cookieNonce !== statePayload.nonce) {
throw new HttpError(400, "bad_request", "OAuth state mismatch");
}
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
const user = await config.prisma.user.upsert({
where: { feishuOpenId: feishuUser.openId },
create: {
feishuOpenId: feishuUser.openId,
displayName: feishuUser.displayName,
avatarUrl: feishuUser.avatarUrl,
},
update: {
displayName: feishuUser.displayName,
avatarUrl: feishuUser.avatarUrl,
},
});
setSessionCookie(reply, config, {
userId: user.id,
feishuOpenId: user.feishuOpenId,
});
const destination = await resolvePostLoginRedirect(
config.prisma,
user.id,
statePayload.returnTo,
);
return reply.redirect(destination);
} catch (err) {
request.log.error({ err }, "feishu oauth callback failed");
return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`);
}
});
app.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send();
});
app.get("/api/me", async (request, reply) => {
try {
const auth = await requireSession(request, reply, guardDeps);
if (auth === null) return;
const memberships = await config.prisma.organizationMembership.findMany({
where: { userId: auth.user.id, revokedAt: null },
select: {
role: true,
organization: {
select: { id: true, slug: true, name: true, status: true },
},
},
orderBy: { createdAt: "asc" },
});
return {
user: {
id: auth.user.id,
feishuOpenId: auth.user.feishuOpenId,
displayName: auth.user.displayName,
avatarUrl: auth.user.avatarUrl,
},
organizations: memberships.map((m) => ({
id: m.organization.id,
slug: m.organization.slug,
name: m.organization.name,
status: m.organization.status,
role: m.role,
})),
};
} catch (err) {
return handleRouteError(reply, err);
}
});
// Minimal login page until admin SPA lands (PR7).
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
const q = request.query as { error?: string; returnTo?: string };
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
const loginHref =
returnTo === "/admin"
? "/auth/feishu"
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`;
const errorHtml =
typeof q.error === "string" && q.error !== ""
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
: "";
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>CPH Org Admin — Login</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:22rem;text-align:center}
a.btn{display:inline-block;margin-top:1rem;padding:.7rem 1.2rem;background:#3370ff;color:#fff;border-radius:8px;text-decoration:none;font-weight:600}
a.btn:hover{background:#245bdb}
.err{color:#c45656;font-size:.9rem}
</style>
</head>
<body>
<div class="card">
<h1>Org Admin</h1>
<p>Sign in with Feishu to manage your organization.</p>
${errorHtml}
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>
</div>
</body>
</html>`;
return reply.type("text/html").send(html);
});
}
export function setSessionCookie(
reply: FastifyReply,
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
identity: { readonly userId: string; readonly feishuOpenId: string },
): void {
const token = signSession(identity, config.sessionSecret);
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
maxAge: 7 * 24 * 60 * 60,
});
}
/** Exported for tests that need a pre-authenticated cookie value. */
export function mintSessionToken(
identity: { readonly userId: string; readonly feishuOpenId: string },
sessionSecret: string,
): string {
return signSession(identity, sessionSecret);
}
export function sessionCookieHeader(token: string): string {
return `${SESSION_COOKIE_NAME}=${token}`;
}
async function resolvePostLoginRedirect(
prisma: PrismaClient,
userId: string,
returnTo: string,
): Promise<string> {
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo;
}
const membership = await prisma.organizationMembership.findFirst({
where: {
userId,
revokedAt: null,
role: { in: ["OWNER", "ADMIN"] },
organization: { status: "ACTIVE" },
},
select: { organization: { select: { slug: true } } },
orderBy: { createdAt: "asc" },
});
if (membership !== null) {
return `/admin/org/${membership.organization.slug}`;
}
// Member-only or no org: still land on a shell page (SPA will explain).
const any = await prisma.organizationMembership.findFirst({
where: { userId, revokedAt: null, organization: { status: "ACTIVE" } },
select: { organization: { select: { slug: true } } },
orderBy: { createdAt: "asc" },
});
if (any !== null) {
return `/admin/org/${any.organization.slug}`;
}
return "/admin/login?error=no_organization";
}
/**
* Only allow relative same-origin paths under /admin to avoid open redirects.
*/
export function sanitizeReturnTo(raw: string): string {
if (raw === "" || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("://")) {
return "/admin";
}
if (!raw.startsWith("/admin")) {
return "/admin";
}
return raw;
}
function trimTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Org explorer HTTP routes under `/api/org/:orgSlug`.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
archiveFolder,
archiveOrgProjectChatBinding,
archiveProject,
createOrgFolder,
createOrgProject,
getOrgProjectDetail,
listOrgExplorer,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../org/explorer.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface ExplorerRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
}
export async function registerExplorerRoutes(
app: FastifyInstance,
config: ExplorerRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/explorer", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await listOrgExplorer(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
name?: unknown;
parentId?: unknown;
sortKey?: unknown;
};
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await createOrgFolder(config.prisma, {
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
});
return reply.status(201).send({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
name?: unknown;
sortKey?: unknown;
parentId?: unknown;
};
const folder = await renameFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return {
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; folderId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const result = await createOrgProject(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.user.feishuOpenId,
name: body.name,
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send(result);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await getOrgProjectDetail(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
return await renameProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId/folder", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be string or null" },
});
}
return await moveOrgProjectToFolder(config.prisma, {
organizationId: auth.organization.id,
projectId,
folderId: body.folderId as string | null,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveOrgProjectChatBinding(config.prisma, {
organizationId: auth.organization.id,
projectId,
actorFeishuOpenId: auth.user.feishuOpenId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+104
View File
@@ -0,0 +1,104 @@
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
addOrgMember,
listOrgMembers,
revokeOrgMember,
setOrgMemberRole,
} from "../../org/members.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"];
export async function registerMembersRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/members", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { members: await listOrgMembers(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/members", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
feishuOpenId?: unknown;
displayName?: unknown;
role?: unknown;
};
if (typeof body.feishuOpenId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "feishuOpenId is required" },
});
}
const role = parseRole(body.role) ?? "MEMBER";
const member = await addOrgMember(config.prisma, {
organizationId: auth.organization.id,
actorRole: auth.membershipRole,
feishuOpenId: body.feishuOpenId,
role,
...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}),
});
return reply.status(201).send(member);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/members/:userId", async (request, reply) => {
try {
const { orgSlug, userId } = request.params as { orgSlug: string; userId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { role?: unknown };
const role = parseRole(body.role);
if (role === null) {
return reply.status(400).send({
error: { code: "bad_request", message: "role must be OWNER|ADMIN|MEMBER" },
});
}
return await setOrgMemberRole(config.prisma, {
organizationId: auth.organization.id,
actorUserId: auth.user.id,
actorRole: auth.membershipRole,
targetUserId: userId,
role,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/members/:userId/revoke", async (request, reply) => {
try {
const { orgSlug, userId } = request.params as { orgSlug: string; userId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await revokeOrgMember(config.prisma, {
organizationId: auth.organization.id,
actorUserId: auth.user.id,
actorRole: auth.membershipRole,
targetUserId: userId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
function parseRole(value: unknown): OrganizationMemberRole | null {
if (typeof value !== "string") return null;
return (ROLES as readonly string[]).includes(value) ? (value as OrganizationMemberRole) : null;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Mount point for `/api/org/:orgSlug/*` org-admin APIs.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import {
ensureOrganizationProjectSettings,
setMembersCanCreateProjects,
} from "../../projectOnboarding.js";
import { registerAccessRoutes } from "./accessRoutes.js";
import { registerExplorerRoutes } from "./explorerRoutes.js";
import { registerMembersRoutes } from "./membersRoutes.js";
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return {
organization: {
id: auth.organization.id,
slug: auth.organization.slug,
name: auth.organization.name,
status: auth.organization.status,
},
actorRole: auth.membershipRole,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/settings", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const settings = await ensureOrganizationProjectSettings(config.prisma, auth.organization.id);
return {
membersCanCreateProjects: settings.membersCanCreateProjects,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/settings", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { membersCanCreateProjects?: unknown };
if (typeof body.membersCanCreateProjects !== "boolean") {
return reply.status(400).send({
error: { code: "bad_request", message: "membersCanCreateProjects must be a boolean" },
});
}
const settings = await setMembersCanCreateProjects(config.prisma, {
organizationId: auth.organization.id,
enabled: body.membersCanCreateProjects,
});
return {
membersCanCreateProjects: settings.membersCanCreateProjects,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
await registerExplorerRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
projectWorkspaceRoot: config.projectWorkspaceRoot,
});
await registerMembersRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerTeamsRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerAccessRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
}
+80
View File
@@ -0,0 +1,80 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { getSessionDetail, listProjectSessions } from "../../org/sessions.js";
import { getOrgUsage, getProjectUsage } from "../../org/usage.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export async function registerSessionsAndUsageRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/projects/:projectId/sessions", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const q = request.query as { limit?: string };
const limit = q.limit !== undefined ? Number(q.limit) : undefined;
return {
sessions: await listProjectSessions(config.prisma, {
organizationId: auth.organization.id,
projectId,
...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
}),
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/sessions/:sessionId", async (request, reply) => {
try {
const { orgSlug, sessionId } = request.params as { orgSlug: string; sessionId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await getSessionDetail(config.prisma, {
organizationId: auth.organization.id,
sessionId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/usage", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const q = request.query as { from?: string; to?: string; folderId?: string };
return await getOrgUsage(config.prisma, {
organizationId: auth.organization.id,
...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}),
...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}),
...(q.folderId !== undefined && q.folderId !== "" ? { folderId: q.folderId } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/projects/:projectId/usage", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const q = request.query as { from?: string; to?: string };
return await getProjectUsage(config.prisma, {
organizationId: auth.organization.id,
projectId,
...(q.from !== undefined && q.from !== "" ? { from: new Date(q.from) } : {}),
...(q.to !== undefined && q.to !== "" ? { to: new Date(q.to) } : {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+140
View File
@@ -0,0 +1,140 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
addTeamMember,
archiveTeam,
createTeam,
listOrgTeams,
listTeamMembers,
revokeTeamMember,
updateTeam,
} from "../../org/teams.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export async function registerTeamsRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/teams", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { teams: await listOrgTeams(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { slug?: unknown; name?: unknown; description?: unknown };
if (typeof body.slug !== "string" || typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "slug and name are required" },
});
}
const team = await createTeam(config.prisma, {
organizationId: auth.organization.id,
slug: body.slug,
name: body.name,
...(typeof body.description === "string" ? { description: body.description } : {}),
});
return reply.status(201).send(team);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/teams/:teamId", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; description?: unknown };
return await updateTeam(config.prisma, {
organizationId: auth.organization.id,
teamId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.description === null || typeof body.description === "string"
? { description: body.description as string | null }
: {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/archive", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveTeam(config.prisma, {
organizationId: auth.organization.id,
teamId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return {
members: await listTeamMembers(config.prisma, {
organizationId: auth.organization.id,
teamId,
}),
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { userId?: unknown; feishuOpenId?: unknown };
const member = await addTeamMember(config.prisma, {
organizationId: auth.organization.id,
teamId,
...(typeof body.userId === "string" ? { userId: body.userId } : {}),
...(typeof body.feishuOpenId === "string" ? { feishuOpenId: body.feishuOpenId } : {}),
});
return reply.status(201).send(member);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/members/:userId/revoke", async (request, reply) => {
try {
const { orgSlug, teamId, userId } = request.params as {
orgSlug: string;
teamId: string;
userId: string;
};
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await revokeTeamMember(config.prisma, {
organizationId: auth.organization.id,
teamId,
userId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+23
View File
@@ -0,0 +1,23 @@
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;
}
+5
View File
@@ -0,0 +1,5 @@
export {
createDefaultModelRegistry,
defaultSonnetModel,
type Env,
} from "../settings/runtime.js";
+12 -8
View File
@@ -17,10 +17,11 @@
* *
* A role bundles everything that distinguishes one agent persona from another: * A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills / * model, system prompt, and the tool surface (files / cph / feishu / skills /
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset}; * mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and
* the model never sees tools outside its role's whitelist, even if it tries to * the cph_hub MCP server builder; security does not rely on the system prompt.
* call them — security does not rely on the system prompt.
*/ */
import { assertSupportedRoleTools } from "./roleTools.js";
export interface RoleEntry { export interface RoleEntry {
readonly id: string; readonly id: string;
/** Human label for the teacher-side switcher UX. */ /** Human label for the teacher-side switcher UX. */
@@ -29,16 +30,16 @@ export interface RoleEntry {
readonly defaultModel: string | undefined; readonly defaultModel: string | undefined;
/** /**
* System prompt seeding the agent's persona/instructions. Prepended to the * System prompt seeding the agent's persona/instructions. Prepended to the
* run's messages only at session start (resume reads it from the transcript, * run's messages only at session start; Claude SDK resume restores later
* see runner.ts). `undefined` ⇒ no system prompt (bare run). * turns from the provider session. `undefined` ⇒ no system prompt (bare run).
*/ */
readonly systemPrompt: string | undefined; readonly systemPrompt?: string | undefined;
/** /**
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered * Tool names this role may use (whitelist). `undefined` ⇒ the full registered
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all. * set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
* Names not present in the registry are silently dropped at run setup. * Invalid names fail fast when settings are loaded or the run is set up.
*/ */
readonly tools: readonly string[] | undefined; readonly tools?: readonly string[] | undefined;
} }
/** A model the admin has enabled for use by the Hub. */ /** A model the admin has enabled for use by the Hub. */
@@ -72,6 +73,9 @@ export class InMemoryModelRegistry implements ModelRegistry {
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) { constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
this.models = models; 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])); this.roles = new Map(roles.map((r) => [r.id, r]));
} }
-18
View File
@@ -1,18 +0,0 @@
/**
* OpenRouter model factory.
*
* ADR-0017: the agent layer keeps role/model resolution separate from provider
* construction. A run carries a resolved model id, and switching model still
* means a new provider-bound session.
*/
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
export function createModelFactory(): (modelId: string) => LanguageModel {
const provider = createOpenAICompatible({
name: "openrouter",
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
});
return (modelId: string) => provider(modelId);
}
+110
View File
@@ -0,0 +1,110 @@
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);
}
+282 -98
View File
@@ -1,140 +1,324 @@
/** /**
* Provider-bound agent runner. * Claude Code SDK agent runner.
* *
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the * Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
* per-run tool context, role/model selection, transcript persistence, and the * owns tool dispatch, context compaction, streaming — we just map its events
* ADR-0017 session boundary: a run is bound to one model. Switching model is a * to the Feishu streaming callback.
* new run + new session; cross-run continuity is carried by project *
* memory/anchors (ADR-0003), not by this runner. * 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 { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai"; import { homedir } from "node:os";
import type { ToolContext, ToolRegistry } from "./tools.js"; import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { readTranscript, appendTranscript } from "./transcript.js"; import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
export type ModelFactory = (modelId: string) => LanguageModel;
/** What the runner needs about the project to seed and bound a run. */
export interface ProjectContext { export interface ProjectContext {
readonly projectId: string; readonly projectId: string;
/** ADR-0001 binding - the chat this project is bound to. */
readonly boundChatId: string; readonly boundChatId: string;
/** Absolute path to the curriculum engineering-file workspace. */
readonly workspaceDir: 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 { export interface RunRequest {
readonly prompt: string; readonly prompt: string;
/** Resolved model id (already chosen by the caller per ADR-0017). */ readonly model: string | undefined;
readonly model: string;
readonly project: ProjectContext; readonly project: ProjectContext;
readonly systemPrompt: string | undefined; readonly systemPrompt: string | undefined;
/** Iteration cap before the run is returned as `length`. Default 25. */ readonly providerEnv?: Record<string, string | undefined> | undefined;
readonly maxIterations?: number; readonly resumeSessionId?: string | undefined;
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */ /**
readonly toolWhitelist?: readonly string[] | undefined; * RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
/** Path to the session transcript JSONL. If set, prior messages are loaded * (for example read_file/cph_check/send_file), mapped to Claude SDK tool
* from it before the run, and new messages are appended after. */ * names at run setup. `undefined` means the full supported tool surface; []
readonly transcriptPath?: string; * 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 type RunStatus = "completed" | "length" | "failed";
export interface RunResult { export interface RunResult {
readonly status: RunStatus; readonly status: RunStatus;
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */ readonly text: string;
readonly messages: readonly ModelMessage[];
readonly usage: { inputTokens: number; outputTokens: number }; 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; readonly error?: string;
} }
const DEFAULT_MAX_ITERATIONS = 25; const DEFAULT_MAX_TURNS = 25;
export async function runAgent( /**
modelFactory: ModelFactory, * Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
tools: ToolRegistry, * confines writes to the workspace; these deny reads of credentials/secrets
req: RunRequest, * the process could otherwise see (read-only mounts are still readable). The
): Promise<RunResult> { * workspace itself is never in this list — it's writable by `allowWrite`.
const ctx: ToolContext = { * Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
runId: cryptoRandomId(), * with additional secret locations.
projectId: req.project.projectId, */
boundChatId: req.project.boundChatId, const SENSITIVE_READ_PATHS: readonly string[] = (() => {
workspaceDir: req.project.workspaceDir, 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 */ }
}; };
const aiTools = tools.build(ctx, req.toolWhitelist);
// Load prior session messages from transcript (continuity across @bot calls
// in the same session). ADR-0003: session messages != group chat history.
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
const loadedCount = messages.length;
if (req.systemPrompt !== undefined && messages.length === 0) {
// System prompt only at the very start of a session; on resume it's
// already in the transcript.
messages = [{ role: "system", content: req.systemPrompt }];
}
messages = [...messages, { role: "user", content: req.prompt }];
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
let fullText = "";
let usage = { inputTokens: 0, outputTokens: 0 };
let costUsd: number | undefined;
let numTurns = 0;
let sdkSessionId: string | undefined;
let error: string | undefined;
try { try {
const result = await generateText({ await persistAgentMessage(req, "user", req.prompt);
model: modelFactory(req.model), const toolConfig = claudeSdkToolConfigForRole(req.tools);
messages,
allowSystemInMessages: true,
tools: aiTools,
stopWhen: stepCountIs(cap),
});
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
await appendTranscriptIfSet(req, allMessages, loadedCount);
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap; const options: Parameters<typeof query>[0]["options"] = {
const status: RunStatus = cwd: req.project.workspaceDir,
result.finishReason === "stop" tools: [...toolConfig.tools],
? "completed" allowedTools: [...toolConfig.allowedTools],
: result.finishReason === "length" || stoppedByStepCap maxTurns: cap,
? "length" includePartialMessages: true,
: "failed"; // ADR-0018: bypass interactive prompts (headless server); the sandbox
const error = // below is the hard boundary, not the permission-prompt layer.
status === "failed" permissionMode: "bypassPermissions" as const,
? `finishReason=${result.finishReason}` allowDangerouslySkipPermissions: true,
: stoppedByStepCap // ADR-0018: OS-level sandbox confines the agent process + its Bash
? `exceeded maxIterations=${cap}` // subprocesses. Writes confined to the workspace; sensitive host paths
: undefined; // denied reads; network open. failIfUnavailable hard-fails if the
// sandbox can't start — never run unsandboxed.
return { sandbox: {
status, enabled: true,
messages: allMessages, failIfUnavailable: true,
usage: { autoAllowBashIfSandboxed: true,
inputTokens: result.usage.inputTokens ?? 0, filesystem: {
outputTokens: result.usage.outputTokens ?? 0, 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 } : {}), ...(error !== undefined ? { error } : {}),
}; };
} catch (e) { } catch (e) {
await appendTranscriptIfSet(req, messages, loadedCount); const aborted = req.abortController?.signal.aborted === true;
return { return {
status: "failed", status: aborted ? "interrupted" : "failed",
messages, text: fullText,
usage: { inputTokens: 0, outputTokens: 0 }, usage,
error: e instanceof Error ? e.message : String(e), ...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
}; };
} }
} }
/// Append only the messages produced this run (skip the `loadedCount` prior async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
/// ones already in the file). Errors are swallowed - a transcript write if (content === "") return;
/// failure must not lose the run result.
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
if (req.transcriptPath === undefined) return;
const newMessages = messages.slice(loadedCount);
if (newMessages.length === 0) return;
try { try {
await appendTranscript(req.transcriptPath, newMessages); await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role,
content,
attachments: [],
},
});
} catch { } catch {
// Swallow - transcript is best-effort persistence; the run result is // Best-effort projection: a history write failure must not break the run.
// returned regardless. ADR-0002 lock ensures no concurrent writer.
} }
} }
function cryptoRandomId(): string { function extractToolResultText(content: unknown): string {
return globalThis.crypto.randomUUID(); 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");
} }
+5
View File
@@ -24,6 +24,11 @@ export interface ToolContext {
readonly boundChatId: string; readonly boundChatId: string;
/** Absolute path to the project's curriculum engineering-file workspace. */ /** Absolute path to the project's curriculum engineering-file workspace. */
readonly workspaceDir: string; 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 ToolDefinition = Tool;
+32
View File
@@ -8,6 +8,7 @@
* surface; the Hub agent is narrower than a general coding agent. * surface; the Hub agent is narrower than a general coding agent.
*/ */
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises"; import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, resolve, relative, isAbsolute } from "node:path"; import { join, resolve, relative, isAbsolute } from "node:path";
import { tool } from "ai"; import { tool } from "ai";
import { z } from "zod"; import { z } from "zod";
@@ -67,8 +68,39 @@ export function writeFileTool(ctx: ToolContext): ToolDefinition {
execute: async (args): Promise<string> => { execute: async (args): Promise<string> => {
try { try {
const full = confine(args.path, ctx.workspaceDir); 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 mkdir(join(full, ".."), { recursive: true });
await writeFile(full, args.content, "utf8"); 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 }); return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
} catch (e) { } catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
+54
View File
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,408 @@
/**
* 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
@@ -0,0 +1,239 @@
/**
* 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
@@ -0,0 +1,225 @@
/**
* 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);
}
+818 -93
View File
@@ -1,17 +1,16 @@
/** /**
* Feishu lark client + long-connection event dispatcher. * Feishu lark client + long-connection event dispatcher.
* *
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The * Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw * message sending, streaming patch via interactive cards, emoji
* event to our handler. This version of the SDK does not auto-normalize on the * reactions, file download/upload, and the card action callback.
* ws path — `normalize()` is a separate helper — so we work with the raw event
* shape directly (it carries chat_id, message_type, content, mentions).
*
* ADR-0001: the bot operates inside a project's bound group. It does not own
* the lock (ADR-0002) and is not the session owner.
*/ */
import * as lark from "@larksuiteoapi/node-sdk"; 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 type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
export interface FeishuConfig { export interface FeishuConfig {
readonly appId: string; readonly appId: string;
@@ -24,14 +23,55 @@ export interface FeishuRuntime {
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
} }
/** Raw `im.message.receive_v1` event payload (subset we use). */ 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 { export interface MessageReceiveEvent {
readonly header?: { /** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
readonly event_id?: string; readonly event_id?: string;
readonly event_type?: string; readonly event_type?: string;
}; readonly header?: { readonly event_id?: string; readonly event_type?: string };
readonly message: { readonly message: {
readonly message_id: string; 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_id: string;
readonly chat_type: string; readonly chat_type: string;
readonly message_type: string; readonly message_type: string;
@@ -48,86 +88,771 @@ export interface MessageReceiveEvent {
}; };
} }
/** Send a text message to a chat. */ /** Card action trigger event (button clicks, select changes). */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> { export interface CardActionEvent {
// SDK exposes im.v1 dynamically at runtime; the generated types are weak readonly operator: { readonly open_id?: string };
// there, so cast through the known shape. readonly action: {
const client = rt.client as unknown as { readonly value?: unknown;
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } }; readonly tag?: string;
readonly option?: string;
}; };
await client.im.v1.message.create({ readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
params: { receive_id_type: "chat_id" }, readonly token?: string;
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
} }
/** Send an interactive card to a chat. `card` is the lark card schema object. */ 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( export async function sendCard(
rt: FeishuRuntime, rt: FeishuRuntime,
chatId: string, chatId: string,
card: unknown, card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> { ): Promise<string | null> {
const client = rt.client as unknown as { try {
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } }; return await sendMessagePayload(
}; rt,
const res = await client.im.v1.message.create({ chatId,
params: { receive_id_type: "chat_id" }, { msgType: "interactive", content: JSON.stringify(card) },
data: { options,
receive_id: chatId, );
msg_type: "interactive", } catch {
content: JSON.stringify(card), return null;
}, }
});
return res.data?.message_id ?? null;
} }
/** Build a run-status card: status text + model selector + cancel button. */ /** Patch an interactive card in-place with raw JSON. */
export function buildRunStatusCard(opts: { export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record<string, unknown>): Promise<boolean> {
readonly status: string; const client = rt.client as unknown as {
readonly model: string; im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
readonly models: readonly { readonly id: string; readonly label: string }[]; };
readonly runId: string; try {
}): unknown { await client.im.v1.message.patch({
return { 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 }, config: { wide_screen_mode: true },
header: { header: { title: { content: title, tag: "plain_text" }, template: "orange" },
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
},
elements: [ elements: [
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } }, { tag: "markdown", content: body },
{ {
tag: "action", tag: "action",
actions: [ actions: buttons.map((button) => ({
{ tag: "button",
tag: "select", text: { tag: "plain_text", content: button.label },
placeholder: { tag: "plain_text", content: "切换 model" }, type: button.style ?? "default",
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })), value: { approval_action: button.value },
value: opts.model, })),
},
{
tag: "button",
text: { tag: "plain_text", content: "取消" },
type: "danger",
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
},
],
}, },
], ],
}; };
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. */
* Start the lark WebSocket client and route `im.message.receive_v1` events to export async function resolveCard(
* `onMessage`. The ws connection lives in the background; this resolves the rt: FeishuRuntime,
* runtime handle. messageId: string,
*/ icon: string,
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools label: string,
* that need to call Feishu APIs can hold it from server boot. */ 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 { export function createLarkClient(config: FeishuConfig): lark.Client {
return new lark.Client({ return new lark.Client({
appId: config.appId, appId: config.appId,
@@ -136,17 +861,12 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
}); });
} }
export interface FeishuListener {
readonly runtime: FeishuRuntime;
readonly stop: () => Promise<void>;
}
/** Start the ws listener using an already-built client. Returns the runtime. */
export function startFeishuListenerWithClient( export function startFeishuListenerWithClient(
config: FeishuConfig, config: FeishuConfig,
client: lark.Client, client: lark.Client,
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>, onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime { ): FeishuRuntime {
const wsClient = new lark.WSClient({ const wsClient = new lark.WSClient({
appId: config.appId, appId: config.appId,
@@ -155,23 +875,28 @@ export function startFeishuListenerWithClient(
loggerLevel: lark.LoggerLevel.info, loggerLevel: lark.LoggerLevel.info,
}); });
const rt: FeishuRuntime = { client, logger }; const rt: FeishuRuntime = { client, logger };
void wsClient.start({ const handlers: Record<string, (data: unknown) => Promise<void>> = {
eventDispatcher: new lark.EventDispatcher({}).register({ "im.message.receive_v1": async (data) => {
"im.message.receive_v1": async (data) => { try { await onMessage(data as MessageReceiveEvent, rt); }
try { catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
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; return rt;
} }
export function startFeishuListener( export function startFeishuListener(
config: FeishuConfig, config: FeishuConfig,
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>, onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime { ): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage); return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
} }
+95
View File
@@ -0,0 +1,95 @@
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
@@ -0,0 +1,166 @@
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
@@ -0,0 +1,125 @@
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
@@ -0,0 +1,154 @@
export interface OnboardingProjectOption {
readonly projectId: string;
readonly name: string;
readonly folderName?: string | undefined;
}
export interface OnboardingFolderOption {
readonly folderId: string;
readonly name: string;
}
export interface ProjectOnboardingActionValue {
readonly action: "create_project_from_chat" | "bind_project";
readonly organization_id: string;
readonly project_id?: string | undefined;
readonly folder_id?: string | undefined;
}
export function buildUnboundChatOnboardingCard(params: {
readonly organizationId: string;
readonly organizationName: string;
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean;
}): Record<string, unknown> {
const actions: unknown[] = [];
if (params.canCreateProject) {
const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3);
for (const folder of folders) {
actions.push({
tag: "button",
text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` },
type: "primary",
value: {
project_onboarding: {
action: "create_project_from_chat",
organization_id: params.organizationId,
...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}),
},
},
});
}
}
for (const project of params.projects.slice(0, 5)) {
actions.push({
tag: "button",
text: { tag: "plain_text", content: buttonProjectLabel(project) },
type: "default",
value: {
project_onboarding: {
action: "bind_project",
organization_id: params.organizationId,
project_id: project.projectId,
},
},
});
}
const elements: unknown[] = [
{
tag: "markdown",
content: [
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
].join("\n"),
},
];
if (actions.length > 0) {
elements.push({ tag: "action", actions });
} else {
elements.push({
tag: "markdown",
content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。",
});
}
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: "绑定项目" },
template: "blue",
},
elements,
};
}
export function buildProjectOnboardingResolvedCard(params: {
readonly title: string;
readonly body: string;
readonly template: "green" | "red";
}): Record<string, unknown> {
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: params.title },
template: params.template,
},
elements: [{ tag: "markdown", content: params.body }],
};
}
export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null {
const raw = unwrapValue(value);
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
if (!("project_onboarding" in raw)) return null;
const action = raw.project_onboarding;
if (typeof action !== "object" || action === null || Array.isArray(action)) return null;
const rawAction = (action as { action?: unknown }).action;
const organizationId = (action as { organization_id?: unknown }).organization_id;
const projectId = (action as { project_id?: unknown }).project_id;
const folderId = (action as { folder_id?: unknown }).folder_id;
if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") {
return null;
}
if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) {
return null;
}
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) {
return null;
}
return {
action: rawAction,
organization_id: organizationId,
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
};
}
function unwrapValue(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function buttonProjectLabel(project: OnboardingProjectOption): string {
const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `;
const label = `${folderPrefix}${project.name}`;
return buttonLabel(label, 24);
}
function buttonLabel(label: string, maxLength: number): string {
return label.length <= maxLength ? label : `${label.slice(0, maxLength - 3)}...`;
}
function escapeMarkdown(value: string): string {
return value.replace(/([`*_{}[\]<>])/g, "\\$1");
}
+9 -3
View File
@@ -24,11 +24,14 @@ interface LarkMessage {
message_id?: string; message_id?: string;
msg_type?: string; msg_type?: string;
content?: string; content?: string;
body?: { content?: string };
create_time?: string; create_time?: string;
sender?: { id?: string; sender_type?: string }; sender?: { id?: string; sender_type?: string };
chat_id?: string; chat_id?: string;
parent_id?: string;
parent_message_id?: string; parent_message_id?: string;
root_id?: string; root_id?: string;
thread_id?: string;
} }
interface ImV1Message { interface ImV1Message {
@@ -84,14 +87,17 @@ export async function readFeishuContext(
/** Project a lark message down to the fields the model actually needs. */ /** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> { function compact(m: LarkMessage): Record<string, unknown> {
const parentId = m.parent_id ?? m.parent_message_id;
return { return {
message_id: m.message_id, message_id: m.message_id,
msg_type: m.msg_type, msg_type: m.msg_type,
content: m.content, content: m.body?.content ?? m.content,
create_time: m.create_time, create_time: m.create_time,
chat_id: m.chat_id,
sender_id: m.sender?.id, sender_id: m.sender?.id,
parent_message_id: m.parent_message_id, parent_id: parentId,
parent_message_id: parentId,
root_id: m.root_id, root_id: m.root_id,
thread_id: m.thread_id,
}; };
} }
+82
View File
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,412 @@
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
@@ -0,0 +1,210 @@
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);
}
}
}
+1210 -246
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
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();
+350
View File
@@ -0,0 +1,350 @@
/**
* Org project explorer (ADR-0021).
*
* Folders are transparent navigation nodes (not ACL resources). Project grants
* stay on PROJECT. Archive folder refuses when active children/projects remain.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import {
archiveFeishuChatBinding,
createFolder,
createProjectFromOrgAdmin,
moveProjectToFolder,
} from "../projectOnboarding.js";
export interface ExplorerFolderNode {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
readonly sortKey: string;
readonly projectCount: number;
readonly childFolderCount: number;
}
export interface ExplorerProjectNode {
readonly id: string;
readonly name: string;
readonly folderId: string | null;
readonly createdAt: string;
readonly binding: { readonly chatId: string; readonly createdAt: string } | null;
}
export interface OrgExplorer {
readonly folders: readonly ExplorerFolderNode[];
readonly projects: readonly ExplorerProjectNode[];
}
export async function listOrgExplorer(
prisma: PrismaClient,
organizationId: string,
): Promise<OrgExplorer> {
const [folders, projects] = await Promise.all([
prisma.folder.findMany({
where: { organizationId, archivedAt: null },
select: {
id: true,
name: true,
parentId: true,
sortKey: true,
_count: {
select: {
children: { where: { archivedAt: null } },
projects: { where: { archivedAt: null } },
},
},
},
orderBy: [{ sortKey: "asc" }, { name: "asc" }],
}),
prisma.project.findMany({
where: { organizationId, archivedAt: null },
select: {
id: true,
name: true,
folderId: true,
createdAt: true,
groupBindings: {
where: { archivedAt: null },
select: { chatId: true, createdAt: true },
take: 1,
},
},
orderBy: [{ name: "asc" }],
}),
]);
return {
folders: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
sortKey: f.sortKey,
projectCount: f._count.projects,
childFolderCount: f._count.children,
})),
projects: projects.map((p) => {
const binding = p.groupBindings[0];
return {
id: p.id,
name: p.name,
folderId: p.folderId,
createdAt: p.createdAt.toISOString(),
binding:
binding === undefined
? null
: { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() },
};
}),
};
}
export async function createOrgFolder(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
readonly sortKey?: string | undefined;
},
) {
return createFolder(prisma, input);
}
export async function renameFolder(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly folderId: string;
readonly name?: string | undefined;
readonly sortKey?: string | undefined;
readonly parentId?: string | null | undefined;
},
) {
return prisma.$transaction(async (tx) => {
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent");
}
await requireActiveFolder(tx, input.parentId, input.organizationId);
}
const name =
input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined;
return tx.folder.update({
where: { id: folder.id },
data: {
...(name !== undefined ? { name } : {}),
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
});
});
}
/**
* Soft-archive a folder. Refuses if it still has active child folders or projects.
*/
export async function archiveFolder(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly folderId: string },
): Promise<{ readonly archived: true; readonly folderId: string }> {
return prisma.$transaction(async (tx) => {
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
const childFolders = await tx.folder.count({
where: { parentId: folder.id, archivedAt: null },
});
if (childFolders > 0) {
throw new Error(
`cannot archive folder ${folder.id}: still has ${childFolders} active child folder(s)`,
);
}
const projects = await tx.project.count({
where: { folderId: folder.id, archivedAt: null },
});
if (projects > 0) {
throw new Error(
`cannot archive folder ${folder.id}: still has ${projects} active project(s)`,
);
}
await tx.folder.update({
where: { id: folder.id },
data: { archivedAt: new Date() },
});
return { archived: true as const, folderId: folder.id };
});
}
export async function createOrgProject(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
},
) {
return createProjectFromOrgAdmin(prisma, input);
}
export async function renameProject(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly name: string;
},
) {
const name = requireNonEmpty(input.name, "project name");
const project = await requireActiveProject(prisma, input.projectId, input.organizationId);
return prisma.project.update({
where: { id: project.id },
data: { name },
select: { id: true, name: true, folderId: true },
});
}
export async function moveOrgProjectToFolder(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly folderId: string | null;
},
) {
await requireActiveProject(prisma, input.projectId, input.organizationId);
return moveProjectToFolder(prisma, {
projectId: input.projectId,
folderId: input.folderId,
});
}
export async function archiveProject(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly projectId: string },
): Promise<{ readonly archived: true; readonly projectId: string }> {
return prisma.$transaction(async (tx) => {
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
const activeBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null },
select: { id: true, chatId: true },
});
const now = new Date();
if (activeBinding !== null) {
await tx.projectGroupBinding.update({
where: { id: activeBinding.id },
data: { archivedAt: now },
});
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "FEISHU_CHAT",
principalId: activeBinding.chatId,
revokedAt: null,
},
data: { revokedAt: now },
});
}
await tx.project.update({
where: { id: project.id },
data: { archivedAt: now },
});
return { archived: true as const, projectId: project.id };
});
}
export async function getOrgProjectDetail(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly projectId: string },
) {
const project = await prisma.project.findFirst({
where: {
id: input.projectId,
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
folderId: true,
workspaceDir: true,
createdAt: true,
archivedAt: true,
createdBy: { select: { id: true, displayName: true, feishuOpenId: true } },
folder: { select: { id: true, name: true } },
groupBindings: {
where: { archivedAt: null },
select: { chatId: true, createdAt: true },
take: 1,
},
},
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const binding = project.groupBindings[0];
return {
id: project.id,
name: project.name,
folderId: project.folderId,
folder: project.folder,
workspaceDir: project.workspaceDir,
createdAt: project.createdAt.toISOString(),
archivedAt: project.archivedAt?.toISOString() ?? null,
createdBy: project.createdBy,
binding:
binding === undefined
? null
: { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() },
};
}
export async function archiveOrgProjectChatBinding(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly actorFeishuOpenId: string;
},
) {
await requireActiveProject(prisma, input.projectId, input.organizationId);
return archiveFeishuChatBinding(prisma, {
projectId: input.projectId,
actorFeishuOpenId: input.actorFeishuOpenId,
});
}
async function requireActiveFolder(
prisma: PrismaClient | Prisma.TransactionClient,
folderId: string,
organizationId: string,
): Promise<{ readonly id: string; readonly organizationId: string }> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { id: true, organizationId: true, archivedAt: true },
});
if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) {
throw new Error(`active folder not found: ${folderId}`);
}
return folder;
}
async function requireActiveProject(
prisma: PrismaClient | Prisma.TransactionClient,
projectId: string,
organizationId: string,
): Promise<{ readonly id: string; readonly organizationId: string }> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true, archivedAt: true },
});
if (project === null || project.archivedAt !== null || project.organizationId !== organizationId) {
throw new Error(`active project not found: ${projectId}`);
}
return project;
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Organization membership management for org admin (ADR-0021).
*
* Role rules (product pin, not yet in Lean):
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
* 3. Cannot revoke or demote the last remaining OWNER.
* 4. ADMIN cannot modify OWNER memberships.
*/
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
export interface OrgMemberRow {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrganizationMemberRole;
readonly createdAt: string;
}
export async function listOrgMembers(
prisma: PrismaClient,
organizationId: string,
): Promise<readonly OrgMemberRow[]> {
const rows = await prisma.organizationMembership.findMany({
where: { organizationId, revokedAt: null },
select: {
role: true,
createdAt: true,
user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
},
},
orderBy: [{ role: "asc" }, { createdAt: "asc" }],
});
return rows.map((row) => ({
userId: row.user.id,
feishuOpenId: row.user.feishuOpenId,
displayName: row.user.displayName,
avatarUrl: row.user.avatarUrl,
role: row.role,
createdAt: row.createdAt.toISOString(),
}));
}
export async function addOrgMember(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorRole: OrganizationMemberRole;
readonly feishuOpenId: string;
readonly displayName?: string | undefined;
readonly role: OrganizationMemberRole;
},
): Promise<OrgMemberRow> {
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
const user = await prisma.user.upsert({
where: { feishuOpenId: openId },
create: {
feishuOpenId: openId,
displayName: input.displayName?.trim() || openId,
},
update: {
...(input.displayName !== undefined && input.displayName.trim() !== ""
? { displayName: input.displayName.trim() }
: {}),
},
});
const existing = await prisma.organizationMembership.findFirst({
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
});
if (existing !== null) {
throw new Error(`user ${user.id} is already an active member`);
}
const membership = await prisma.organizationMembership.create({
data: {
organizationId: input.organizationId,
userId: user.id,
role: input.role,
},
});
return {
userId: user.id,
feishuOpenId: user.feishuOpenId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: membership.role,
createdAt: membership.createdAt.toISOString(),
};
}
export async function setOrgMemberRole(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorUserId: string;
readonly actorRole: OrganizationMemberRole;
readonly targetUserId: string;
readonly role: OrganizationMemberRole;
},
): Promise<OrgMemberRow> {
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
select: {
id: true,
role: true,
createdAt: true,
user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
},
},
});
if (membership === null) {
throw new Error(`member not found: ${input.targetUserId}`);
}
assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER");
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot modify OWNER membership as ADMIN");
}
if (membership.role === "OWNER" && input.role !== "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
}
const updated = await prisma.organizationMembership.update({
where: { id: membership.id },
data: { role: input.role },
});
return {
userId: membership.user.id,
feishuOpenId: membership.user.feishuOpenId,
displayName: membership.user.displayName,
avatarUrl: membership.user.avatarUrl,
role: updated.role,
createdAt: membership.createdAt.toISOString(),
};
}
export async function revokeOrgMember(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorUserId: string;
readonly actorRole: OrganizationMemberRole;
readonly targetUserId: string;
},
): Promise<{ readonly revoked: true; readonly userId: string }> {
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.targetUserId,
revokedAt: null,
},
select: { id: true, role: true },
});
if (membership === null) {
throw new Error(`member not found: ${input.targetUserId}`);
}
if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot revoke OWNER membership as ADMIN");
}
if (membership.role === "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId);
}
await prisma.organizationMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
return { revoked: true as const, userId: input.targetUserId };
}
function assertCanAssignRole(
actorRole: OrganizationMemberRole,
newRole: OrganizationMemberRole,
targetIsOwner: boolean,
): void {
if (newRole === "OWNER" && actorRole !== "OWNER") {
throw new Error("only OWNER can grant OWNER role");
}
if (targetIsOwner && actorRole !== "OWNER") {
throw new Error("cannot modify OWNER membership as ADMIN");
}
}
async function assertNotLastOwner(
prisma: PrismaClient,
organizationId: string,
targetUserId: string,
): Promise<void> {
const owners = await prisma.organizationMembership.count({
where: { organizationId, role: "OWNER", revokedAt: null },
});
if (owners <= 1) {
throw new Error(`cannot revoke or demote last OWNER (${targetUserId})`);
}
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Agent session / run listing for org admin.
*/
import type { PrismaClient } from "@prisma/client";
export async function listProjectSessions(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly limit?: number | undefined;
},
) {
const project = await prisma.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId },
select: { id: true },
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
const sessions = await prisma.agentSession.findMany({
where: { projectId: project.id, archivedAt: null },
select: {
id: true,
provider: true,
roleId: true,
model: true,
title: true,
createdAt: true,
updatedAt: true,
_count: { select: { runs: true } },
},
orderBy: { updatedAt: "desc" },
take: limit,
});
return sessions.map((s) => ({
id: s.id,
provider: s.provider,
roleId: s.roleId,
model: s.model,
title: s.title,
runCount: s._count.runs,
createdAt: s.createdAt.toISOString(),
updatedAt: s.updatedAt.toISOString(),
}));
}
export async function getSessionDetail(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly sessionId: string },
) {
const session = await prisma.agentSession.findUnique({
where: { id: input.sessionId },
select: {
id: true,
provider: true,
roleId: true,
model: true,
title: true,
createdAt: true,
updatedAt: true,
archivedAt: true,
project: {
select: { id: true, name: true, organizationId: true },
},
runs: {
select: {
id: true,
status: true,
model: true,
provider: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
startedAt: true,
finishedAt: true,
error: true,
},
orderBy: { startedAt: "desc" },
take: 100,
},
},
});
if (session === null || session.project.organizationId !== input.organizationId) {
throw new Error(`session not found: ${input.sessionId}`);
}
return {
id: session.id,
provider: session.provider,
roleId: session.roleId,
model: session.model,
title: session.title,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
archivedAt: session.archivedAt?.toISOString() ?? null,
project: {
id: session.project.id,
name: session.project.name,
},
runs: session.runs.map((r) => ({
id: r.id,
status: r.status,
model: r.model,
provider: r.provider,
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costUsd: r.costUsd === null ? null : Number(r.costUsd),
startedAt: r.startedAt.toISOString(),
finishedAt: r.finishedAt?.toISOString() ?? null,
error: r.error,
})),
};
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
*
* Archiving a team soft-archives the team row and revokes active TEAM→PROJECT
* grants that use this team as principal (product pin).
*/
import type { Prisma, PrismaClient } from "@prisma/client";
export interface TeamRow {
readonly id: string;
readonly slug: string;
readonly name: string;
readonly description: string | null;
readonly memberCount: number;
readonly createdAt: string;
}
export interface TeamMemberRow {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly createdAt: string;
}
export async function listOrgTeams(
prisma: PrismaClient,
organizationId: string,
): Promise<readonly TeamRow[]> {
const teams = await prisma.team.findMany({
where: { organizationId, archivedAt: null },
select: {
id: true,
slug: true,
name: true,
description: true,
createdAt: true,
_count: { select: { memberships: { where: { revokedAt: null } } } },
},
orderBy: { slug: "asc" },
});
return teams.map((t) => ({
id: t.id,
slug: t.slug,
name: t.name,
description: t.description,
memberCount: t._count.memberships,
createdAt: t.createdAt.toISOString(),
}));
}
export async function createTeam(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly slug: string;
readonly name: string;
readonly description?: string | undefined;
},
): Promise<TeamRow> {
const slug = sanitizeSlug(input.slug);
const name = requireNonEmpty(input.name, "name");
const existing = await prisma.team.findFirst({
where: { organizationId: input.organizationId, slug, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
throw new Error(`team slug already exists: ${slug}`);
}
const team = await prisma.team.create({
data: {
organizationId: input.organizationId,
slug,
name,
...(input.description !== undefined ? { description: input.description } : {}),
},
});
return {
id: team.id,
slug: team.slug,
name: team.name,
description: team.description,
memberCount: 0,
createdAt: team.createdAt.toISOString(),
};
}
export async function updateTeam(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly teamId: string;
readonly name?: string | undefined;
readonly description?: string | null | undefined;
},
): Promise<TeamRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
const updated = await prisma.team.update({
where: { id: team.id },
data: {
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
},
select: {
id: true,
slug: true,
name: true,
description: true,
createdAt: true,
_count: { select: { memberships: { where: { revokedAt: null } } } },
},
});
return {
id: updated.id,
slug: updated.slug,
name: updated.name,
description: updated.description,
memberCount: updated._count.memberships,
createdAt: updated.createdAt.toISOString(),
};
}
/**
* Soft-archive team and revoke its active project grants (TEAM principal).
*/
export async function archiveTeam(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly teamId: string },
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
return prisma.$transaction(async (tx) => {
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const now = new Date();
await tx.team.update({
where: { id: team.id },
data: { archivedAt: now },
});
await tx.teamMembership.updateMany({
where: { teamId: team.id, revokedAt: null },
data: { revokedAt: now },
});
const grants = await tx.permissionGrant.updateMany({
where: {
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: now },
});
return {
archived: true as const,
teamId: team.id,
revokedGrants: grants.count,
};
});
}
export async function listTeamMembers(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly teamId: string },
): Promise<readonly TeamMemberRow[]> {
await requireActiveTeam(prisma, input.teamId, input.organizationId);
const rows = await prisma.teamMembership.findMany({
where: { teamId: input.teamId, revokedAt: null },
select: {
createdAt: true,
user: { select: { id: true, feishuOpenId: true, displayName: true } },
},
orderBy: { createdAt: "asc" },
});
return rows.map((row) => ({
userId: row.user.id,
feishuOpenId: row.user.feishuOpenId,
displayName: row.user.displayName,
createdAt: row.createdAt.toISOString(),
}));
}
export async function addTeamMember(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly teamId: string;
readonly userId?: string | undefined;
readonly feishuOpenId?: string | undefined;
},
): Promise<TeamMemberRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId);
const user = await resolveUser(prisma, input);
// User should be an org member to join a team (product pin for pilot).
const membership = await prisma.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: user.id,
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error(`user ${user.id} is not an active member of the organization`);
}
const existing = await prisma.teamMembership.findFirst({
where: { teamId: team.id, userId: user.id, revokedAt: null },
});
if (existing !== null) {
throw new Error(`user ${user.id} is already on team ${team.id}`);
}
const row = await prisma.teamMembership.create({
data: { teamId: team.id, userId: user.id },
});
return {
userId: user.id,
feishuOpenId: user.feishuOpenId,
displayName: user.displayName,
createdAt: row.createdAt.toISOString(),
};
}
export async function revokeTeamMember(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly teamId: string;
readonly userId: string;
},
): Promise<{ readonly revoked: true; readonly userId: string }> {
await requireActiveTeam(prisma, input.teamId, input.organizationId);
const membership = await prisma.teamMembership.findFirst({
where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
select: { id: true },
});
if (membership === null) {
throw new Error(`team member not found: ${input.userId}`);
}
await prisma.teamMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
return { revoked: true as const, userId: input.userId };
}
async function resolveUser(
prisma: PrismaClient,
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
if (input.userId !== undefined && input.userId !== "") {
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { id: true, feishuOpenId: true, displayName: true },
});
if (user === null) throw new Error(`user not found: ${input.userId}`);
return user;
}
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
const user = await prisma.user.findUnique({
where: { feishuOpenId: input.feishuOpenId },
select: { id: true, feishuOpenId: true, displayName: true },
});
if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`);
return user;
}
throw new Error("userId or feishuOpenId is required");
}
async function requireActiveTeam(
prisma: PrismaClient | Prisma.TransactionClient,
teamId: string,
organizationId: string,
): Promise<{ readonly id: string }> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { id: true, organizationId: true, archivedAt: true },
});
if (team === null || team.archivedAt !== null || team.organizationId !== organizationId) {
throw new Error(`active team not found: ${teamId}`);
}
return team;
}
function sanitizeSlug(raw: string): string {
const slug = requireNonEmpty(raw, "slug").toLowerCase();
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) {
throw new Error("slug must be lowercase alphanumeric with optional hyphens");
}
return slug;
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Org / project usage rollups from AgentRun cost facts (ADR-0021).
* Not payment collection — customers may supply their own provider keys.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
export interface ProjectUsageRow {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
}
export interface UsageReport {
readonly from: string | null;
readonly to: string | null;
readonly projects: readonly ProjectUsageRow[];
readonly totals: {
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
};
}
export async function getOrgUsage(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly from?: Date | undefined;
readonly to?: Date | undefined;
readonly folderId?: string | undefined;
},
): Promise<UsageReport> {
const projectWhere: Prisma.ProjectWhereInput = {
organizationId: input.organizationId,
...(input.folderId !== undefined ? { folderId: input.folderId } : {}),
};
const projects = await prisma.project.findMany({
where: projectWhere,
select: { id: true, name: true, folderId: true },
orderBy: { name: "asc" },
});
if (projects.length === 0) {
return emptyReport(input.from, input.to);
}
const runWhere: Prisma.AgentRunWhereInput = {
projectId: { in: projects.map((p) => p.id) },
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
};
const runs = await prisma.agentRun.findMany({
where: runWhere,
select: {
projectId: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
});
type MutableRow = {
projectId: string;
projectName: string;
folderId: string | null;
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
const byProject = new Map<string, MutableRow>();
for (const p of projects) {
byProject.set(p.id, {
projectId: p.id,
projectName: p.name,
folderId: p.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
});
}
for (const run of runs) {
const row = byProject.get(run.projectId);
if (row === undefined) continue;
row.runCount += 1;
row.inputTokens += run.inputTokens ?? 0;
row.outputTokens += run.outputTokens ?? 0;
if (run.costUsd === null) {
row.runsWithoutCost += 1;
} else {
row.runsWithCost += 1;
const cost = Number(run.costUsd);
row.costUsd = (row.costUsd ?? 0) + cost;
}
}
const projectsOut: ProjectUsageRow[] = [...byProject.values()];
let runCount = 0;
let runsWithCost = 0;
let runsWithoutCost = 0;
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
for (const row of projectsOut) {
runCount += row.runCount;
runsWithCost += row.runsWithCost;
runsWithoutCost += row.runsWithoutCost;
inputTokens += row.inputTokens;
outputTokens += row.outputTokens;
if (row.costUsd !== null) {
costUsd = (costUsd ?? 0) + row.costUsd;
}
}
return {
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
projects: projectsOut,
totals: {
runCount,
runsWithCost,
runsWithoutCost,
inputTokens,
outputTokens,
costUsd,
},
};
}
export async function getProjectUsage(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly from?: Date | undefined;
readonly to?: Date | undefined;
},
): Promise<ProjectUsageRow> {
const project = await prisma.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId },
select: { id: true, name: true, folderId: true },
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const report = await getOrgUsage(prisma, {
organizationId: input.organizationId,
from: input.from,
to: input.to,
});
const row = report.projects.find((p) => p.projectId === project.id);
return (
row ?? {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
}
);
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
},
};
}
+45 -74
View File
@@ -1,89 +1,70 @@
/** /**
* Permission gate — ADR-0004 in code, project-scope. * Permission compatibility exports.
* *
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted * ADR-0019 moves production authorization behind PrincipalResolver +
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec * PermissionAuthorizer. The wrappers below preserve the old helper names for
* `Role.can_mono`). This module checks that for the run's requester against * tests and incremental callers while routing project trigger checks through
* the project resource. * the new authorizer.
*
* Scope decision (project-wise): the resource is `project`, not
* `project_group` — the run and lock scope to the project (ADR-0002), so the
* capability is checked against the project. per-artifact permission is `OPEN`
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
*
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
* use the sender's Feishu open_id as the principal string literal — a pragmatic
* choice for the first cut, not a spec decision. When a real principal model
* lands, this is the single seam to change.
*
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
* capabilities from settings policy knobs but doesn't pin the composition
* rule). This gate implements the role half only; settings composition is
* deferred and clearly marked.
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { PermissionRole } from "@prisma/client"; import { createPermissionAuthorizer } from "./permissions/authorizer.js";
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity). export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]); 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 { export interface PermissionResult {
readonly allowed: boolean; readonly allowed: boolean;
readonly reason: string; readonly reason: string;
} }
/**
* Check whether `principal` may trigger an agent run on `projectId`.
*
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
* caller replies with the reason. Never throws — a DB error is a deny with the
* error as reason, so the run is not started on a broken state.
*/
export async function canTriggerAgent( export async function canTriggerAgent(
prisma: PrismaClient, prisma: PrismaClient,
projectId: string, projectId: string,
principal: string, principal: string,
): Promise<PermissionResult> { ): Promise<PermissionResult> {
try { try {
// Look for an active (non-revoked) grant on the project resource for this const decision = await createPermissionAuthorizer(prisma).can({
// principal, with a role that grants edit or higher. actor: { feishuOpenId: principal },
const grant = await prisma.permissionGrant.findFirst({ action: "agent.trigger",
where: { resource: { type: "PROJECT", id: projectId },
resourceType: "PROJECT",
resourceId: projectId,
principal,
role: { in: ["EDIT", "MANAGE"] },
revokedAt: null,
},
select: { id: true, role: true },
}); });
if (grant !== null) { return { allowed: decision.allowed, reason: decision.reason };
return { allowed: true, reason: `granted by role ${grant.role}` };
}
return {
allowed: false,
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
};
} catch (e) { } catch (e) {
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` }; return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
} }
} }
/** /**
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to * Role-only legacy helper. New trigger code uses `role.trigger` on
* {@link canTriggerAgent}: that decides "can trigger an agent at all" * PermissionAuthorizer so project edit+ and role grant composition happen in
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role". * one decision.
* Two gates in series — both must pass.
*
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
* (regardless of principal), the role is unconfigured on this project →
* **allow** (back-compat: a project that hasn't configured per-role grants
* doesn't get locked down). Once any grant exists for that role on the
* project, only principals with an active grant may trigger it. "Grants exist
* but this principal has none" → deny (admin explicitly restricted the role).
*
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
* same opaque string ADR-0004 uses (sender open_id here; principal
* sub-typology OPEN).
*/ */
export async function canTriggerRole( export async function canTriggerRole(
prisma: PrismaClient, prisma: PrismaClient,
@@ -92,10 +73,6 @@ export async function canTriggerRole(
principal: string, principal: string,
): Promise<PermissionResult> { ): Promise<PermissionResult> {
try { try {
// Any grant record (active OR revoked) for this (project, role)? If none,
// the role is unconfigured → allow (back-compat: no per-role restriction).
// A revoked grant still counts as "configured" — revoking one person must
// not silently reopen the role to everyone.
const anyGrant = await prisma.roleTriggerGrant.findFirst({ const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId }, where: { projectId, roleId },
select: { id: true }, select: { id: true },
@@ -103,9 +80,8 @@ export async function canTriggerRole(
if (anyGrant === null) { if (anyGrant === null) {
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` }; return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
} }
// Grants exist — this principal must hold one.
const grant = await prisma.roleTriggerGrant.findFirst({ const grant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId, principal, revokedAt: null }, where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
select: { id: true }, select: { id: true },
}); });
if (grant !== null) { if (grant !== null) {
@@ -119,8 +95,3 @@ export async function canTriggerRole(
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` }; return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
} }
} }
/// Whether a role grants edit capability (edit or manage). Exported for tests.
export function roleGrantsEdit(role: PermissionRole): boolean {
return EDIT_OR_HIGHER.has(role);
}
+295
View File
@@ -0,0 +1,295 @@
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
@@ -0,0 +1,149 @@
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
@@ -0,0 +1,152 @@
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
@@ -0,0 +1,218 @@
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
@@ -0,0 +1,537 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permission.js";
import { writeAudit } from "./audit.js";
export interface OrganizationProjectPolicy {
readonly organizationId: string;
readonly membersCanCreateProjects: boolean;
}
export interface ProjectOnboardingResult {
readonly projectId: string;
readonly organizationId: string;
readonly folderId: string | null;
readonly workspaceDir: string;
readonly chatId?: string | undefined;
}
export interface CreateOrgAdminProjectInput {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface CreateFeishuChatProjectInput {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly chatId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface BindFeishuChatToProjectInput {
readonly projectId: string;
readonly actorFeishuOpenId: string;
readonly chatId: string;
}
export interface ArchiveFeishuChatBindingInput {
readonly projectId?: string | undefined;
readonly chatId?: string | undefined;
readonly actorFeishuOpenId: string;
}
export interface CreateFolderInput {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface MoveProjectToFolderInput {
readonly projectId: string;
readonly folderId: string | null;
}
export async function ensureOrganizationProjectSettings(
prisma: PrismaClient,
organizationId: string,
): Promise<OrganizationProjectPolicy> {
return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId));
}
export async function setMembersCanCreateProjects(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly enabled: boolean },
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExists(prisma, input.organizationId);
const settings = await prisma.organizationProjectSettings.upsert({
where: { organizationId: input.organizationId },
update: { membersCanCreateProjects: input.enabled },
create: {
organizationId: input.organizationId,
membersCanCreateProjects: input.enabled,
},
select: { organizationId: true, membersCanCreateProjects: true },
});
return settings;
}
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
const name = requireNonEmpty(input.name, "folder name");
return prisma.$transaction(async (tx) => {
await ensureOrganizationExistsTx(tx, input.organizationId);
if (input.parentId !== undefined) {
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
}
return tx.folder.create({
data: {
organizationId: input.organizationId,
name,
sortKey: input.sortKey ?? "",
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
});
});
}
export async function moveProjectToFolder(
prisma: PrismaClient,
input: MoveProjectToFolderInput,
): Promise<{ readonly projectId: string; readonly folderId: string | null }> {
return prisma.$transaction(async (tx) => {
const project = await tx.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
if (input.folderId !== null) {
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
}
const updated = await tx.project.update({
where: { id: input.projectId },
data: { folderId: input.folderId },
select: { id: true, folderId: true },
});
return { projectId: updated.id, folderId: updated.folderId };
});
}
export async function createProjectFromOrgAdmin(
prisma: PrismaClient,
input: CreateOrgAdminProjectInput,
): Promise<ProjectOnboardingResult> {
const name = requireNonEmpty(input.name, "project name");
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
if (!isOrgAdminRole(actor.role)) {
throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`);
}
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
chatId: undefined,
});
}
export async function createProjectFromFeishuChat(
prisma: PrismaClient,
input: CreateFeishuChatProjectInput,
): Promise<ProjectOnboardingResult> {
const chatId = requireNonEmpty(input.chatId, "chat id");
const name = requireNonEmpty(input.name, "project name");
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId);
if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) {
throw new Error(`members cannot create projects in organization ${input.organizationId}`);
}
const activeBinding = await prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeBinding !== null) {
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`);
}
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
chatId,
});
}
export async function bindFeishuChatToProject(
prisma: PrismaClient,
input: BindFeishuChatToProjectInput,
): Promise<ProjectOnboardingResult> {
const chatId = requireNonEmpty(input.chatId, "chat id");
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => {
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null },
select: { chatId: true },
});
if (activeProjectBinding !== null) {
throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`);
}
const activeChatBinding = await tx.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeChatBinding !== null) {
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
}
await tx.projectGroupBinding.create({
data: { projectId: project.id, chatId, createdByUserId: actor.userId },
});
await replaceProjectGrant(tx, {
projectId: project.id,
principalType: "FEISHU_CHAT",
principalId: chatId,
role: "EDIT",
createdByUserId: actor.userId,
});
});
await writeAudit(prisma, {
projectId: project.id,
actorUserId: actor.userId,
action: "project.chat_bound",
metadata: { chatId, actorVia: actor.via },
});
return {
projectId: project.id,
organizationId: project.organizationId,
folderId: project.folderId,
workspaceDir: project.workspaceDir,
chatId,
};
}
export async function archiveFeishuChatBinding(
prisma: PrismaClient,
input: ArchiveFeishuChatBindingInput,
): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> {
if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) {
throw new Error("archiveFeishuChatBinding requires projectId or chatId");
}
if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") {
throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId");
}
const binding = await prisma.projectGroupBinding.findFirst({
where: {
archivedAt: null,
...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }),
},
select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } },
});
if (binding === null) return { archived: false };
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => {
await tx.projectGroupBinding.update({
where: { id: binding.id },
data: { archivedAt: new Date() },
});
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: binding.projectId,
principalType: "FEISHU_CHAT",
principalId: binding.chatId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
});
await writeAudit(prisma, {
projectId: binding.projectId,
actorUserId: actor.userId,
action: "project.chat_binding_archived",
metadata: { chatId: binding.chatId, actorVia: actor.via },
});
return { archived: true, projectId: binding.projectId, chatId: binding.chatId };
}
async function createManagedProject(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorUserId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId: string | undefined;
readonly sortKey?: string | undefined;
readonly chatId: string | undefined;
},
): Promise<ProjectOnboardingResult> {
const organization = await prisma.organization.findUnique({
where: { id: input.organizationId },
select: { id: true, slug: true },
});
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
const projectId = createProjectId();
const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug,
projectId,
});
await mkdir(workspaceDir, { recursive: true });
const project = await prisma.$transaction(async (tx) => {
await ensureOrganizationProjectSettingsTx(tx, organization.id);
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
await assertFolderInOrganization(tx, folderId, organization.id);
const created = await tx.project.create({
data: {
id: projectId,
organizationId: organization.id,
folderId,
name: input.name,
workspaceDir,
createdByUserId: input.actorUserId,
},
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
});
await tx.permissionSettings.create({
data: defaultProjectPermissionSettings(created.id),
});
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "USER",
principalId: input.actorFeishuOpenId,
role: "MANAGE",
createdByUserId: input.actorUserId,
});
if (input.chatId !== undefined) {
await tx.projectGroupBinding.create({
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
});
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "FEISHU_CHAT",
principalId: input.chatId,
role: "EDIT",
createdByUserId: input.actorUserId,
});
}
return created;
});
await writeAudit(prisma, {
projectId: project.id,
actorUserId: input.actorUserId,
action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat",
metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null },
});
return {
projectId: project.id,
organizationId: project.organizationId,
folderId: project.folderId,
workspaceDir: project.workspaceDir,
...(input.chatId !== undefined ? { chatId: input.chatId } : {}),
};
}
async function requireProjectManager(
prisma: PrismaClient,
projectId: string,
organizationId: string,
actorFeishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> {
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
if (isOrgAdminRole(actor.role)) {
return { ...actor, via: "org-admin" };
}
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: actorFeishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`);
}
return { ...actor, via: "project-manage" };
}
async function requireActiveOrgMember(
prisma: PrismaClient,
organizationId: string,
feishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
const user = await prisma.user.findUnique({
where: { feishuOpenId },
select: {
id: true,
organizationMemberships: {
where: { organizationId, revokedAt: null },
select: { role: true },
take: 1,
},
},
});
if (user === null) {
throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`);
}
const membership = user.organizationMemberships[0];
if (membership === undefined) {
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
}
return { userId: user.id, role: membership.role };
}
async function ensureOrganizationProjectSettingsTx(
prisma: Prisma.TransactionClient,
organizationId: string,
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExistsTx(prisma, organizationId);
return prisma.organizationProjectSettings.upsert({
where: { organizationId },
update: {},
create: { organizationId, membersCanCreateProjects: true },
select: { organizationId: true, membersCanCreateProjects: true },
});
}
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true },
});
if (existing !== null) return existing;
return prisma.folder.create({
data: { organizationId, name: "Inbox", sortKey: "000000" },
select: { id: true },
});
}
async function assertFolderInOrganization(
prisma: Prisma.TransactionClient,
folderId: string,
organizationId: string,
): Promise<void> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { organizationId: true, archivedAt: true },
});
if (folder === null || folder.archivedAt !== null) {
throw new Error(`active folder not found: ${folderId}`);
}
if (folder.organizationId !== organizationId) {
throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`);
}
}
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
}
async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
}
async function replaceProjectGrant(
prisma: Prisma.TransactionClient,
input: {
readonly projectId: string;
readonly principalType: "USER" | "FEISHU_CHAT";
readonly principalId: string;
readonly role: PermissionRole;
readonly createdByUserId: string;
},
): Promise<void> {
await prisma.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: input.projectId,
principalType: input.principalType,
principalId: input.principalId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: input.projectId,
principalType: input.principalType,
principalId: input.principalId,
role: input.role,
createdByUserId: input.createdByUserId,
},
});
}
function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput {
return {
resourceType: "PROJECT",
resourceId: projectId,
externalShare: "DISABLED",
comment: "ROLE",
copyDownload: "ROLE",
collaboratorMgmt: "MANAGE_ONLY",
agentTrigger: "ROLE",
agentCancel: "MANAGE_ONLY",
};
}
function isOrgAdminRole(role: OrganizationMemberRole): boolean {
return role === "OWNER" || role === "ADMIN";
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
function createProjectId(): string {
return `project_${randomUUID().replaceAll("-", "")}`;
}
function projectWorkspaceDir(input: {
readonly workspaceRoot: string;
readonly organizationSlug: string;
readonly projectId: string;
}): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id"));
const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`);
}
return dir;
}
function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") {
throw new Error(`${label} is not a safe path segment`);
}
return segment;
}
+56 -57
View File
@@ -1,5 +1,5 @@
/** /**
* Hub entry — Fastify server + Feishu WebSocket listener. * Hub entry — Fastify server + Feishu WebSocket listener + org admin HTTP.
* *
* Wires the full trigger path: lark ws receives `im.message.receive_v1` → * Wires the full trigger path: lark ws receives `im.message.receive_v1` →
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires * trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
@@ -7,20 +7,20 @@
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the * (ADR-0017), and releases the lock on finish. Feishu context reads inside the
* loop are ADR-0003-authorized (chat must match binding). * loop are ADR-0003-authorized (chat must match binding).
* *
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
*
* Env (see .env.example): * Env (see .env.example):
* DATABASE_URL, OPENROUTER_API_KEY, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT * DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
*/ */
import "dotenv/config"; import "dotenv/config";
import Fastify from "fastify"; import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createModelFactory } from "./agent/provider.js"; import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { InMemoryModelRegistry } from "./agent/models.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
import { readFeishuContext } from "./feishu/read.js";
import { makeTriggerHandler } from "./feishu/trigger.js"; import { makeTriggerHandler } from "./feishu/trigger.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
function requireEnv(name: string): string { function requireEnv(name: string): string {
const v = process.env[name]; const v = process.env[name];
@@ -31,66 +31,65 @@ function requireEnv(name: string): string {
return v; 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> { async function main(): Promise<void> {
const databaseUrl = requireEnv("DATABASE_URL"); const databaseUrl = requireEnv("DATABASE_URL");
void databaseUrl; // prisma reads DATABASE_URL from env directly void databaseUrl;
requireEnv("OPENROUTER_API_KEY");
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const port = Number(process.env["PORT"] ?? "8788"); const port = Number(process.env["PORT"] ?? "8788");
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
// 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() })); app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
// --- Agent seam wiring --- await registerAdminPlugin(app, {
const modelFactory = createModelFactory(); prisma,
const models = new InMemoryModelRegistry( sessionSecret,
[ publicBaseUrl,
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, feishuAppId,
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true }, feishuAppSecret,
], projectWorkspaceRoot,
[ });
{
id: "draft",
label: "草稿",
defaultModel: "z-ai/glm-4.6",
systemPrompt: undefined,
// Drafting needs full workspace + cph access.
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
},
{
id: "review",
label: "审校",
defaultModel: "anthropic/claude-3.5-sonnet",
systemPrompt: undefined,
// Review is read-only on artifacts; no write_file, no build.
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
},
],
);
// Build the lark client first — tools that call Feishu APIs hold it from boot. // --- Feishu listener ---
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
const larkClient = createLarkClient(feishuConfig); if (feishuListenerEnabled) {
const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log }; const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
const larkClient = createLarkClient(feishuConfig);
const tools = new ToolRegistry(); const triggerQueuePurgeTimer = setInterval(() => {
// ADR-0003: the handler enforces chat==boundChat before any API call; const removed = triggerQueue.purgeExpired();
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list. if (removed > 0) {
tools.register("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt)); app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
// Workspace file tools (ADR-0007 directory tree, path-confined). }
tools.register("read_file", readFileTool); }, 60_000);
tools.register("write_file", writeFileTool); triggerQueuePurgeTimer.unref();
tools.register("list_files", listFilesTool); const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
// cph CLI tools (ADR-0016 version contract enforced by cph itself). startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
tools.register("cph_check", cphCheckTool); } else {
tools.register("cph_build", cphBuildTool); app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
// --- Feishu listener (reuses the same lark client) ---
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
app.listen({ port, host: "0.0.0.0" }, (err, address) => { app.listen({ port, host: "0.0.0.0" }, (err, address) => {
if (err !== null) { if (err !== null) {
+128
View File
@@ -0,0 +1,128 @@
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
@@ -0,0 +1,250 @@
/**
* Org admin auth + requireOrgRole integration tests (ADR-0021).
*/
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
const PUBLIC_BASE = "http://127.0.0.1:8788";
async function buildApp(fetchImpl?: typeof fetch) {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: PUBLIC_BASE,
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
cookieSecure: false,
...(fetchImpl !== undefined ? { fetchImpl } : {}),
});
await app.ready();
return app;
}
async function seedUser(
id: string,
openId: string,
role: "OWNER" | "ADMIN" | "MEMBER" = "ADMIN",
orgId: string = DEFAULT_ORG_ID,
): Promise<void> {
await prisma.user.create({
data: { id, feishuOpenId: openId, displayName: id },
});
await prisma.organizationMembership.create({
data: { organizationId: orgId, userId: id, role },
});
}
describe("admin auth + org API guards", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("GET /api/me returns 401 without session", async () => {
const app = await buildApp();
try {
const res = await app.inject({ method: "GET", url: "/api/me" });
expect(res.statusCode).toBe(401);
expect(res.json().error.code).toBe("unauthenticated");
} finally {
await app.close();
}
});
it("GET /api/me returns user and memberships with valid session", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp();
try {
const token = mintSessionToken(
{ userId: "u-admin", feishuOpenId: "ou_admin" },
SESSION_SECRET,
);
const res = await app.inject({
method: "GET",
url: "/api/me",
headers: { cookie: sessionCookieHeader(token) },
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
user: { id: string; feishuOpenId: string };
organizations: Array<{ slug: string; role: string }>;
};
expect(body.user.id).toBe("u-admin");
expect(body.user.feishuOpenId).toBe("ou_admin");
expect(body.organizations).toEqual([
expect.objectContaining({ slug: "test-default", role: "ADMIN" }),
]);
} finally {
await app.close();
}
});
it("org summary requires OWNER or ADMIN", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp();
try {
const memberToken = mintSessionToken(
{ userId: "u-member", feishuOpenId: "ou_member" },
SESSION_SECRET,
);
const memberRes = await app.inject({
method: "GET",
url: "/api/org/test-default",
headers: { cookie: sessionCookieHeader(memberToken) },
});
expect(memberRes.statusCode).toBe(403);
const adminToken = mintSessionToken(
{ userId: "u-admin", feishuOpenId: "ou_admin" },
SESSION_SECRET,
);
const adminRes = await app.inject({
method: "GET",
url: "/api/org/test-default",
headers: { cookie: sessionCookieHeader(adminToken) },
});
expect(adminRes.statusCode).toBe(200);
expect(adminRes.json()).toEqual({
organization: expect.objectContaining({
id: DEFAULT_ORG_ID,
slug: "test-default",
}),
actorRole: "ADMIN",
});
} finally {
await app.close();
}
});
it("PATCH settings updates membersCanCreateProjects", async () => {
await seedUser("u-owner", "ou_owner", "OWNER");
const app = await buildApp();
try {
const token = mintSessionToken(
{ userId: "u-owner", feishuOpenId: "ou_owner" },
SESSION_SECRET,
);
const res = await app.inject({
method: "PATCH",
url: "/api/org/test-default/settings",
headers: {
cookie: sessionCookieHeader(token),
"content-type": "application/json",
},
payload: { membersCanCreateProjects: false },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ membersCanCreateProjects: false });
const getRes = await app.inject({
method: "GET",
url: "/api/org/test-default/settings",
headers: { cookie: sessionCookieHeader(token) },
});
expect(getRes.json()).toEqual({ membersCanCreateProjects: false });
} finally {
await app.close();
}
});
it("GET /auth/feishu redirects to Feishu authorize URL", async () => {
const app = await buildApp();
try {
const res = await app.inject({
method: "GET",
url: "/auth/feishu?returnTo=/admin/org/test-default",
});
expect(res.statusCode).toBe(302);
const location = res.headers.location;
expect(location).toBeTypeOf("string");
const url = new URL(location as string);
expect(url.hostname).toBe("accounts.feishu.cn");
expect(url.searchParams.get("client_id")).toBe("cli_test");
expect(url.searchParams.get("redirect_uri")).toBe(
`${PUBLIC_BASE}/auth/feishu/callback`,
);
expect(url.searchParams.get("state")).toBeTruthy();
const setCookie = res.headers["set-cookie"];
expect(JSON.stringify(setCookie)).toContain(OAUTH_STATE_COOKIE_NAME);
} finally {
await app.close();
}
});
it("OAuth callback upserts user and sets session cookie", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/oauth/token")) {
return new Response(
JSON.stringify({ code: 0, access_token: "u-tok", token_type: "Bearer", expires_in: 7200 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.includes("/user_info")) {
return new Response(
JSON.stringify({
code: 0,
data: { open_id: "ou_new", name: "New User", avatar_url: null },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`unexpected ${url}`);
});
const app = await buildApp(fetchImpl as unknown as typeof fetch);
try {
const nonce = "nonce-test-1";
const state = signOAuthState(
{ nonce, returnTo: "/admin/org/test-default" },
SESSION_SECRET,
);
const res = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin/org/test-default");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
expect(user?.displayName).toBe("New User");
} finally {
await app.close();
}
});
it("unknown org slug returns 404 for admin", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp();
try {
const token = mintSessionToken(
{ userId: "u-admin", feishuOpenId: "ou_admin" },
SESSION_SECRET,
);
const res = await app.inject({
method: "GET",
url: "/api/org/does-not-exist",
headers: { cookie: sessionCookieHeader(token) },
});
expect(res.statusCode).toBe(404);
} finally {
await app.close();
}
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Org explorer API integration tests (ADR-0021).
*/
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Fastify from "fastify";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
const workspaceRoots: string[] = [];
async function buildApp(workspaceRoot: string) {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: "http://127.0.0.1:8788",
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: workspaceRoot,
cookieSecure: false,
});
await app.ready();
return app;
}
async function seedAdmin(): Promise<string> {
await prisma.user.create({
data: { id: "u-admin", feishuOpenId: "ou_admin", displayName: "Admin" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-admin", role: "ADMIN" },
});
return mintSessionToken({ userId: "u-admin", feishuOpenId: "ou_admin" }, SESSION_SECRET);
}
async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-admin-explorer-"));
workspaceRoots.push(root);
return root;
}
describe("admin explorer API", () => {
beforeEach(async () => {
await resetDb();
});
afterEach(async () => {
while (workspaceRoots.length > 0) {
const root = workspaceRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => {
await prisma.$disconnect();
});
it("creates folder + project and lists them in explorer", async () => {
const token = await seedAdmin();
const workspaceRoot = await tempWorkspaceRoot();
const app = await buildApp(workspaceRoot);
try {
const folderRes = await app.inject({
method: "POST",
url: "/api/org/test-default/folders",
headers: {
cookie: sessionCookieHeader(token),
"content-type": "application/json",
},
payload: { name: "Grade 7", sortKey: "010000" },
});
expect(folderRes.statusCode).toBe(201);
const folder = folderRes.json() as { id: string };
const projectRes = await app.inject({
method: "POST",
url: "/api/org/test-default/projects",
headers: {
cookie: sessionCookieHeader(token),
"content-type": "application/json",
},
payload: { name: "Newton", folderId: folder.id },
});
expect(projectRes.statusCode).toBe(201);
const project = projectRes.json() as {
projectId: string;
folderId: string;
workspaceDir: string;
};
expect(project.folderId).toBe(folder.id);
expect((await stat(project.workspaceDir)).isDirectory()).toBe(true);
const explorerRes = await app.inject({
method: "GET",
url: "/api/org/test-default/explorer",
headers: { cookie: sessionCookieHeader(token) },
});
expect(explorerRes.statusCode).toBe(200);
const explorer = explorerRes.json() as {
folders: Array<{ id: string; name: string; projectCount: number }>;
projects: Array<{ id: string; name: string; folderId: string | null }>;
};
expect(explorer.folders.some((f) => f.id === folder.id && f.projectCount === 1)).toBe(true);
expect(explorer.projects).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: project.projectId,
name: "Newton",
folderId: folder.id,
}),
]),
);
} finally {
await app.close();
}
});
it("moves project between folders and refuses archive of non-empty folder", async () => {
const token = await seedAdmin();
const workspaceRoot = await tempWorkspaceRoot();
const app = await buildApp(workspaceRoot);
try {
const cookie = sessionCookieHeader(token);
const a = (
await app.inject({
method: "POST",
url: "/api/org/test-default/folders",
headers: { cookie, "content-type": "application/json" },
payload: { name: "A" },
})
).json() as { id: string };
const b = (
await app.inject({
method: "POST",
url: "/api/org/test-default/folders",
headers: { cookie, "content-type": "application/json" },
payload: { name: "B" },
})
).json() as { id: string };
const project = (
await app.inject({
method: "POST",
url: "/api/org/test-default/projects",
headers: { cookie, "content-type": "application/json" },
payload: { name: "P", folderId: a.id },
})
).json() as { projectId: string };
const moveRes = await app.inject({
method: "PATCH",
url: `/api/org/test-default/projects/${project.projectId}/folder`,
headers: { cookie, "content-type": "application/json" },
payload: { folderId: b.id },
});
expect(moveRes.statusCode).toBe(200);
expect(moveRes.json()).toEqual({
projectId: project.projectId,
folderId: b.id,
});
const refuse = await app.inject({
method: "POST",
url: `/api/org/test-default/folders/${b.id}/archive`,
headers: { cookie },
});
expect({ status: refuse.statusCode, body: refuse.json() }).toEqual({
status: 403,
body: expect.objectContaining({
error: expect.objectContaining({ code: "forbidden" }),
}),
});
await app.inject({
method: "POST",
url: `/api/org/test-default/projects/${project.projectId}/archive`,
headers: { cookie },
});
const ok = await app.inject({
method: "POST",
url: `/api/org/test-default/folders/${b.id}/archive`,
headers: { cookie },
});
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ archived: true, folderId: b.id });
} finally {
await app.close();
}
});
it("blocks cross-org project access by id", async () => {
const token = await seedAdmin();
await prisma.organization.create({
data: { id: "org_other", slug: "other", name: "Other" },
});
await prisma.project.create({
data: {
id: "p-other",
organizationId: "org_other",
name: "Other project",
workspaceDir: "/tmp/other",
},
});
const workspaceRoot = await tempWorkspaceRoot();
const app = await buildApp(workspaceRoot);
try {
const res = await app.inject({
method: "GET",
url: "/api/org/test-default/projects/p-other",
headers: { cookie: sessionCookieHeader(token) },
});
expect(res.statusCode).toBe(404);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,161 @@
/**
* Org members + teams + team-access API tests.
*/
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
async function buildApp() {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: "http://127.0.0.1:8788",
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-ws",
cookieSecure: false,
});
await app.ready();
return app;
}
async function seedOwner(): Promise<string> {
await prisma.user.create({
data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" },
});
return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET);
}
describe("admin members + teams API", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("adds members and enforces last-OWNER protection", async () => {
const token = await seedOwner();
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
const add = await app.inject({
method: "POST",
url: "/api/org/test-default/members",
headers: { cookie, "content-type": "application/json" },
payload: { feishuOpenId: "ou_teacher", displayName: "Teacher", role: "ADMIN" },
});
expect(add.statusCode).toBe(201);
const teacher = add.json() as { userId: string; role: string };
expect(teacher.role).toBe("ADMIN");
const list = await app.inject({
method: "GET",
url: "/api/org/test-default/members",
headers: { cookie },
});
expect(list.statusCode).toBe(200);
expect((list.json() as { members: unknown[] }).members).toHaveLength(2);
const refuse = await app.inject({
method: "POST",
url: "/api/org/test-default/members/u-owner/revoke",
headers: { cookie },
});
expect(refuse.statusCode).toBe(403);
expect(JSON.stringify(refuse.json())).toContain("last OWNER");
} finally {
await app.close();
}
});
it("creates team, adds member, grants project access, archives team", async () => {
const token = await seedOwner();
await prisma.user.create({
data: { id: "u-m", feishuOpenId: "ou_m", displayName: "M" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-m", role: "MEMBER" },
});
await prisma.project.create({
data: {
id: "p1",
organizationId: DEFAULT_ORG_ID,
name: "P1",
workspaceDir: "/tmp/p1",
},
});
const app = await buildApp();
try {
const cookie = sessionCookieHeader(token);
const teamRes = await app.inject({
method: "POST",
url: "/api/org/test-default/teams",
headers: { cookie, "content-type": "application/json" },
payload: { slug: "math", name: "Math Team" },
});
expect(teamRes.statusCode).toBe(201);
const team = teamRes.json() as { id: string };
const memberRes = await app.inject({
method: "POST",
url: `/api/org/test-default/teams/${team.id}/members`,
headers: { cookie, "content-type": "application/json" },
payload: { userId: "u-m" },
});
expect(memberRes.statusCode).toBe(201);
const grantRes = await app.inject({
method: "PUT",
url: "/api/org/test-default/projects/p1/team-access",
headers: { cookie, "content-type": "application/json" },
payload: { teamId: team.id, role: "EDIT" },
});
expect(grantRes.statusCode).toBe(200);
expect(grantRes.json()).toEqual(
expect.objectContaining({ teamId: team.id, role: "EDIT" }),
);
const listAccess = await app.inject({
method: "GET",
url: "/api/org/test-default/projects/p1/team-access",
headers: { cookie },
});
expect((listAccess.json() as { access: unknown[] }).access).toHaveLength(1);
const archive = await app.inject({
method: "POST",
url: `/api/org/test-default/teams/${team.id}/archive`,
headers: { cookie },
});
expect(archive.statusCode).toBe(200);
expect(archive.json()).toEqual(
expect.objectContaining({ archived: true, revokedGrants: 1 }),
);
const after = await app.inject({
method: "GET",
url: "/api/org/test-default/projects/p1/team-access",
headers: { cookie },
});
expect((after.json() as { access: unknown[] }).access).toHaveLength(0);
} finally {
await app.close();
}
});
});
+448
View File
@@ -0,0 +1,448 @@
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",
};
}
+216 -13
View File
@@ -12,12 +12,14 @@ import type {
LanguageModelV4CallOptions, LanguageModelV4CallOptions,
LanguageModelV4Content, LanguageModelV4Content,
LanguageModelV4GenerateResult, LanguageModelV4GenerateResult,
LanguageModelV4StreamPart,
LanguageModelV4StreamResult, LanguageModelV4StreamResult,
} from "@ai-sdk/provider"; } from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js"; import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.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 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({ export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } }, datasources: { db: { url: TEST_DATABASE_URL } },
@@ -27,20 +29,66 @@ export const prisma = new PrismaClient({
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
const tables = [ const tables = [
"FeishuEventReceipt", "FeishuEventReceipt",
"AgentFileChange",
"AgentMessage",
"AuditEntry", "AuditEntry",
"PermissionSettings", "PermissionSettings",
"PermissionGrant", "PermissionGrant",
"RoleTriggerGrant", "RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock", "ProjectAgentLock",
"AgentRun", "AgentRun",
"AgentSession", "AgentSession",
"ProjectGroupBinding", "ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment", "PlatformRoleAssignment",
"User", "User",
"Project", "Project",
"Organization",
]; ];
// Truncate with CASCADE to wipe dependent rows in one shot. // Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`); 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). */ /** A logger that discards everything (tests don't need fastify's pino). */
@@ -54,33 +102,99 @@ export const silentLogger: FastifyBaseLogger = {
export interface MockFeishuRuntime extends FeishuRuntime { export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[]; readonly sentTexts: string[];
readonly sentCards: unknown[]; 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 { export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = []; const sentTexts: string[] = [];
const sentCards: unknown[] = []; 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 // 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; // 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. // the actual send functions cast through shape, so a minimal stub works.
const rt: MockFeishuRuntime = { const rt: MockFeishuRuntime = {
client: { 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: { im: {
v1: { v1: {
message: { message: {
create: async (p: unknown) => { create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } }; const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") { recordSentMessage(payload.data, sentTexts, sentCards);
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
} else {
try {
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? payload.data?.content ?? "");
} catch {
sentTexts.push(payload.data?.content ?? "");
}
}
return { data: { message_id: "mock-msg-id" } }; 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 } };
},
}, },
}, },
}, },
@@ -88,10 +202,60 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
logger: silentLogger, logger: silentLogger,
sentTexts, sentTexts,
sentCards, sentCards,
sentPatches,
sentReplies,
reactions,
readableMessages,
threadMessages,
}; };
return rt; 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 { export interface MockToolCall {
readonly toolCallId: string; readonly toolCallId: string;
readonly toolName: string; readonly toolName: string;
@@ -151,8 +315,44 @@ export class MockLanguageModel implements LanguageModelV4 {
}; };
} }
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> { async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
throw new Error("MockLanguageModel does not implement streaming"); 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" },
};
} }
} }
@@ -181,6 +381,7 @@ export async function seedProject(
await prisma.project.create({ await prisma.project.create({
data: { data: {
id: projectId, id: projectId,
organizationId: DEFAULT_ORG_ID,
name: `Test ${projectId}`, name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`, workspaceDir: `/tmp/test-${projectId}`,
}, },
@@ -191,11 +392,13 @@ export async function seedProject(
feishuOpenId: principal, feishuOpenId: principal,
displayName: "Test User", displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } }, platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
permissionGrants: { permissionGrants: {
create: { create: {
resourceType: "PROJECT", resourceType: "PROJECT",
resourceId: projectId, resourceId: projectId,
principal, principalType: "USER",
principalId: principal,
role, role,
}, },
}, },
+5 -5
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js"; import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
@@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("acquireLock + currentLockRunId round-trip", async () => { it("acquireLock + currentLockRunId round-trip", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" }, data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
}); });
const run = await prisma.agentRun.create({ const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
@@ -26,7 +26,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("acquireLock fails when project already locked (exclusivity)", async () => { it("acquireLock fails when project already locked (exclusivity)", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" }, data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
}); });
const run1 = await prisma.agentRun.create({ const run1 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
@@ -41,7 +41,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => { it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" }, 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. // Create a run that is already COMPLETED (terminal), then manually insert a lock.
const run = await prisma.agentRun.create({ const run = await prisma.agentRun.create({
@@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("releaseLock is idempotent", async () => { it("releaseLock is idempotent", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" }, data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
}); });
const run = await prisma.agentRun.create({ const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
@@ -0,0 +1,178 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import {
archiveFeishuChatBinding,
bindFeishuChatToProject,
createFolder,
createProjectFromFeishuChat,
createProjectFromOrgAdmin,
setMembersCanCreateProjects,
} from "../../src/projectOnboarding.js";
const workspaceRoots: string[] = [];
describe("ADR-0021 project onboarding", () => {
beforeEach(async () => {
await resetDb();
});
afterEach(async () => {
while (workspaceRoots.length > 0) {
const root = workspaceRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => {
await prisma.$disconnect();
});
it("lets an org admin create an unbound project in a folder", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const folder = await createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: "Grade 7",
sortKey: "010000",
});
const workspaceRoot = await tempWorkspaceRoot();
const result = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_admin",
name: "Newton Lesson",
folderId: folder.id,
workspaceRoot,
});
expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
const grant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: result.projectId,
principalType: "USER",
principalId: "ou_admin",
revokedAt: null,
},
select: { role: true },
});
expect(grant?.role).toBe("MANAGE");
const settings = await prisma.permissionSettings.findUnique({
where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } },
});
expect(settings?.agentTrigger).toBe("ROLE");
});
it("lets an org member create and bind a project from an unbound Feishu chat", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
const result = await createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-onboard",
name: "Poetry Workshop",
workspaceRoot,
});
expect(result.chatId).toBe("chat-onboard");
const binding = await prisma.projectGroupBinding.findFirst({
where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null },
});
expect(binding).not.toBeNull();
const grants = await prisma.permissionGrant.findMany({
where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null },
select: { principalType: true, principalId: true, role: true },
orderBy: { principalType: "asc" },
});
expect(grants).toEqual(expect.arrayContaining([
{ principalType: "USER", principalId: "ou_member", role: "MANAGE" },
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" },
]));
});
it("blocks ordinary Feishu project creation when the org setting is off", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
await setMembersCanCreateProjects(prisma, {
organizationId: DEFAULT_ORG_ID,
enabled: false,
});
await expect(createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-denied",
name: "Denied Project",
workspaceRoot: await tempWorkspaceRoot(),
})).rejects.toThrow(/members cannot create projects/);
});
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
await seedUser("u-owner", "ou_owner", "OWNER");
const workspaceRoot = await tempWorkspaceRoot();
const project = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
name: "Reusable Project",
workspaceRoot,
});
await bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-first",
});
await expect(bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-second",
})).rejects.toThrow(/already bound/);
await expect(archiveFeishuChatBinding(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
})).resolves.toMatchObject({ archived: true, chatId: "chat-first" });
await bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-second",
});
const activeBindings = await prisma.projectGroupBinding.findMany({
where: { projectId: project.projectId, archivedAt: null },
select: { chatId: true },
});
expect(activeBindings).toEqual([{ chatId: "chat-second" }]);
const oldChatGrant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: project.projectId,
principalType: "FEISHU_CHAT",
principalId: "chat-first",
revokedAt: null,
},
});
expect(oldChatGrant).toBeNull();
});
});
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
await prisma.user.create({
data: {
id,
feishuOpenId,
displayName: feishuOpenId,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
},
});
}
async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-onboarding-"));
workspaceRoots.push(root);
return root;
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 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);
});
+12 -12
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { canTriggerRole } from "../../src/permission.js"; import { canTriggerRole } from "../../src/permission.js";
describe("canTriggerRole (integration, per-role gate)", () => { describe("canTriggerRole (integration, per-role gate)", () => {
@@ -13,7 +13,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("allows when role is unconfigured on the project (back-compat: open)", async () => { it("allows when role is unconfigured on the project (back-compat: open)", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" }, data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
}); });
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a"); const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(r.allowed).toBe(true); expect(r.allowed).toBe(true);
@@ -21,10 +21,10 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("allows when principal holds an active grant for the role", async () => { it("allows when principal holds an active grant for the role", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" }, data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
}); });
await prisma.roleTriggerGrant.create({ await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" }, data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
}); });
const r = await canTriggerRole(prisma, project.id, "review", "ou_a"); const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(true); expect(r.allowed).toBe(true);
@@ -32,11 +32,11 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("denies when grants exist for the role but principal has none", async () => { it("denies when grants exist for the role but principal has none", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" }, data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
}); });
// Someone else has the role; ou_b does not. // Someone else has the role; ou_b does not.
await prisma.roleTriggerGrant.create({ await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" }, data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
}); });
const r = await canTriggerRole(prisma, project.id, "review", "ou_b"); const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
expect(r.allowed).toBe(false); expect(r.allowed).toBe(false);
@@ -44,24 +44,24 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("denies when the principal's grant was revoked", async () => { it("denies when the principal's grant was revoked", async () => {
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" }, data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
}); });
await prisma.roleTriggerGrant.create({ await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() }, data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
}); });
const r = await canTriggerRole(prisma, project.id, "review", "ou_a"); const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(false); expect(r.allowed).toBe(false);
}); });
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => { 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", name: "T", workspaceDir: "/tmp/x" } }); 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", 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({ await prisma.roleTriggerGrant.create({
data: { projectId: p1.id, roleId: "review", principal: "ou_a" }, 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. // p2 has the role configured for someone else; ou_a has no grant there.
await prisma.roleTriggerGrant.create({ await prisma.roleTriggerGrant.create({
data: { projectId: p2.id, roleId: "review", principal: "ou_other" }, data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
}); });
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a"); const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
expect(onP1.allowed).toBe(true); expect(onP1.allowed).toBe(true);
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,189 @@
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"];
}
@@ -0,0 +1,131 @@
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
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,141 @@
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
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
exchangeCodeForUser,
type FeishuOAuthConfig,
} from "../../src/admin/auth/feishuOAuth.js";
describe("buildAuthorizeUrl", () => {
it("includes client_id, redirect, state, and scope", () => {
const url = buildAuthorizeUrl(
{
appId: "cli_test",
appSecret: "secret",
redirectUri: "http://localhost:8788/auth/feishu/callback",
scope: "contact:user.base:readonly",
},
"state-token",
);
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://accounts.feishu.cn/open-apis/authen/v1/authorize",
);
expect(parsed.searchParams.get("client_id")).toBe("cli_test");
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"http://localhost:8788/auth/feishu/callback",
);
expect(parsed.searchParams.get("state")).toBe("state-token");
expect(parsed.searchParams.get("scope")).toBe("contact:user.base:readonly");
});
});
describe("exchangeCodeForUser", () => {
it("exchanges code then loads user_info", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/oauth/token")) {
expect(init?.method).toBe("POST");
const body = JSON.parse(String(init?.body)) as { code: string };
expect(body.code).toBe("auth-code");
return new Response(
JSON.stringify({ code: 0, access_token: "u-token", token_type: "Bearer", expires_in: 7200 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.includes("/user_info")) {
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer u-token");
return new Response(
JSON.stringify({
code: 0,
data: { open_id: "ou_alice", name: "Alice", avatar_url: "https://img/a.png" },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`unexpected url ${url}`);
});
const config: FeishuOAuthConfig = {
appId: "cli_test",
appSecret: "secret",
redirectUri: "http://localhost:8788/auth/feishu/callback",
scope: "contact:user.base:readonly",
fetchImpl: fetchImpl as unknown as typeof fetch,
};
const user = await exchangeCodeForUser(config, "auth-code");
expect(user).toEqual({
openId: "ou_alice",
displayName: "Alice",
avatarUrl: "https://img/a.png",
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("throws on token exchange failure", async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
exchangeCodeForUser(
{
appId: "cli",
appSecret: "s",
redirectUri: "http://localhost/cb",
scope: "",
fetchImpl: fetchImpl as unknown as typeof fetch,
},
"bad",
),
).rejects.toThrow(/token exchange failed/);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { parsePostMessage } from "../../src/feishu/client.js";
describe("parsePostMessage", () => {
it("parses a simple text post", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "text", text: "Hello" }]] },
}));
expect(result).toEqual({ text: "Hello", imageKeys: [], fileKeys: [] });
});
it("prepends a post title as a markdown header", () => {
const result = parsePostMessage(postContent({
zh_cn: { title: "Lesson Notes", content: [[{ tag: "text", text: "Hello" }]] },
}));
expect(result.text).toBe("# Lesson Notes\n\nHello");
});
it("converts links to markdown", () => {
const result = parsePostMessage(postContent({
zh_cn: {
content: [[
{ tag: "text", text: "Read " },
{ tag: "a", text: "docs", href: "https://example.com" },
]],
},
}));
expect(result.text).toBe("Read [docs](https://example.com)");
});
it("converts mentions to readable @names", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "at", user_id: "@_user_1", user_name: "John" }]] },
}));
expect(result.text).toBe("@John");
});
it("converts code blocks to fenced markdown", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "code_block", language: "ts", text: "const x = 1;" }]] },
}));
expect(result.text).toBe("```ts\nconst x = 1;\n```");
});
it("collects image keys and inserts an image placeholder", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "img", image_key: "img_key_here" }]] },
}));
expect(result).toEqual({ text: "[Image]", imageKeys: ["img_key_here"], fileKeys: [] });
});
it("collects file keys and inserts an attachment placeholder", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "file", file_key: "file_key_here", file_name: "worksheet.pdf" }]] },
}));
expect(result).toEqual({
text: "[Attachment: worksheet.pdf]",
imageKeys: [],
fileKeys: ["file_key_here"],
});
});
it("falls back to en_us when zh_cn is missing", () => {
const result = parsePostMessage(postContent({
en_us: { content: [[{ tag: "text", text: "Hello from English" }]] },
}));
expect(result.text).toBe("Hello from English");
});
it("unwraps content nested under a post key", () => {
const result = parsePostMessage(postContent({
post: {
zh_cn: { content: [[{ tag: "text", text: "Wrapped" }]] },
},
}));
expect(result.text).toBe("Wrapped");
});
it("joins multiple rows with newlines", () => {
const result = parsePostMessage(postContent({
zh_cn: {
content: [
[{ tag: "text", text: "First row" }],
[{ tag: "text", text: "Second row" }],
],
},
}));
expect(result.text).toBe("First row\nSecond row");
});
});
function postContent(value: unknown): string {
return JSON.stringify(value);
}
+300
View File
@@ -0,0 +1,300 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js";
import { addReaction, removeReaction } from "../../src/feishu/client.js";
import { makeTriggerHandler } from "../../src/feishu/trigger.js";
import type { PermissionAuthorizer } from "../../src/permission.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
describe("Feishu reactions", () => {
it("addReaction returns reaction_id on success", async () => {
const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } }));
const rt = mockRuntime({ request });
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1");
expect(request).toHaveBeenCalledWith({
method: "POST",
url: "/open-apis/im/v1/messages/message-1/reactions",
data: { reaction_type: { emoji_type: "Typing" } },
});
});
it("addReaction returns null on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull();
});
it("removeReaction returns true on success", async () => {
const request = vi.fn(async () => ({}));
const rt = mockRuntime({ request });
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true);
expect(request).toHaveBeenCalledWith({
method: "DELETE",
url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1",
});
});
it("removeReaction returns false on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
req.onStream?.({ type: "text-delta", text: "done" });
return run.promise;
});
await triggerWithRunAgent(runAgent, rt);
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
]);
run.resolve(completedRunResult("done"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
]);
});
});
it("replaces Typing with CrossMark on failure", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn(() => run.promise);
await triggerWithRunAgent(runAgent, rt);
run.reject(new Error("agent failed"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" },
]);
});
});
});
async function triggerWithRunAgent(
runAgent: (req: RunRequest) => Promise<RunResult>,
rt: MockRuntime,
): Promise<void> {
const trigger = makeTriggerHandler({
prisma: mockPrisma(),
settings: mockSettings(),
logger: rt.logger,
authorizer: allowAllAuthorizer(),
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent(), rt);
}
function completedRunResult(text: string): RunResult {
return {
status: "completed",
text,
usage: { inputTokens: 1, outputTokens: 1 },
numTurns: 1,
};
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function makeEvent(): MessageReceiveEvent {
return {
message: {
message_id: "message-1",
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "@_user_1 do work" }),
mentions: [bot],
},
sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" },
};
}
type ReactionRequest =
| { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string }
| { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string };
interface MockRuntime extends FeishuRuntime {
readonly reactionRequests: ReactionRequest[];
}
function mockRuntime(options: { readonly request?: (payload: unknown) => Promise<unknown> } = {}): MockRuntime {
const reactionRequests: ReactionRequest[] = [];
let nextReactionId = 1;
const request =
options.request ??
vi.fn(async (payload: unknown) => {
const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } };
const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/);
if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) {
const reactionId = `reaction-${nextReactionId}`;
nextReactionId += 1;
reactionRequests.push({
kind: "add",
messageId: addMatch[1],
emoji: requestPayload.data?.reaction_type?.emoji_type ?? "",
reactionId,
});
return { data: { reaction_id: reactionId } };
}
const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/);
if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) {
reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] });
}
return {};
});
return {
client: {
request,
im: {
v1: {
message: {
create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })),
patch: vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger(),
reactionRequests,
};
}
function mockSettings(): RuntimeSettings {
const registry = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "Draft", defaultModel: "mock-model" }],
);
return {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://example.invalid",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {},
};
},
async modelRegistry() {
return registry;
},
async runPolicy() {
return { maxTurns: 1 };
},
};
}
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 mockPrisma(): PrismaClient {
let lock: { projectId: string; runId: string } | null = null;
const session = { id: "session-1", metadata: {} };
return {
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
},
projectGroupBinding: {
findFirst: vi.fn(async () => ({ projectId: "project-1" })),
},
project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
},
agentSession: {
findFirst: vi.fn(async () => null),
create: vi.fn(async () => session),
update: vi.fn(async () => session),
},
agentRun: {
create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
},
projectAgentLock: {
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
lock = { projectId: args.data.projectId, runId: args.data.runId };
return lock;
}),
deleteMany: vi.fn(async () => {
lock = null;
return { count: 1 };
}),
},
auditEntry: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
} as unknown as PrismaClient;
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { readFeishuContext } from "../../src/feishu/read.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
describe("readFeishuContext", () => {
it("compacts Feishu message.get replies with parent_id, thread_id, and body.content", async () => {
const rt = {
client: {
im: {
v1: {
message: {
get: async () => ({
data: {
items: [
{
message_id: "m-child",
root_id: "m-root",
parent_id: "m-parent",
thread_id: "thread-1",
msg_type: "text",
body: { content: JSON.stringify({ text: "parent text" }) },
},
],
},
}),
list: async () => ({ data: { items: [] } }),
},
},
},
},
logger: { warn() {}, info() {}, error() {}, debug() {} },
} as unknown as FeishuRuntime;
const raw = await readFeishuContext(
{ chat_id: "chat-1", anchor: "reply", id: "m-child" },
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
rt,
);
expect(JSON.parse(raw)).toMatchObject({
message_id: "m-child",
content: JSON.stringify({ text: "parent text" }),
parent_id: "m-parent",
parent_message_id: "m-parent",
root_id: "m-root",
thread_id: "thread-1",
});
});
});
+163
View File
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
describe("withRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("succeeds on first attempt without delaying", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const fn = vi.fn(async () => "ok");
await expect(withRetry(fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
it("retries on failure then succeeds", async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce("ok");
const result = withRetry(fn);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("exhausts all attempts and rethrows", async () => {
const error = new Error("still failing");
const fn = vi.fn(async () => {
throw error;
});
const result = withRetry(fn);
const assertion = expect(result).rejects.toBe(error);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await assertion;
expect(fn).toHaveBeenCalledTimes(3);
});
it("respects shouldRetry predicate", async () => {
const error = new Error("validation failure");
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const shouldRetry = vi.fn(() => false);
const fn = vi.fn(async () => {
throw error;
});
await expect(withRetry(fn, { shouldRetry })).rejects.toBe(error);
expect(fn).toHaveBeenCalledTimes(1);
expect(shouldRetry).toHaveBeenCalledWith(error);
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
it("uses exponential backoff delays", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const fn = vi.fn()
.mockRejectedValueOnce(new Error("first failure"))
.mockRejectedValueOnce(new Error("second failure"))
.mockResolvedValueOnce("ok");
const result = withRetry(fn);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await expect(result).resolves.toBe("ok");
expect(setTimeoutSpy.mock.calls.map(([, delay]) => delay)).toEqual([1000, 2000]);
});
});
describe("sendFile retry", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries upload on transient failure", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-retry-"));
try {
const file = join(dir, "student.pdf");
await writeFile(file, "pdf bytes");
let failFirstUpload: (error: Error) => void = () => {
throw new Error("upload rejection was not initialized");
};
const firstUpload = new Promise<never>((_resolve, reject) => {
failFirstUpload = reject;
});
const fileCreate = vi.fn()
.mockReturnValueOnce(firstUpload)
.mockResolvedValueOnce({ data: { file_key: "file-key-1" } });
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ fileCreate, messageCreate });
const result = sendFile(rt, "chat-1", file, "student.pdf");
await waitForCalls(fileCreate, 1);
vi.useFakeTimers();
failFirstUpload(new Error("temporary upload failure"));
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe("message-1");
expect(fileCreate).toHaveBeenCalledTimes(2);
expect(messageCreate).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
});
async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (mock.mock.calls.length >= expectedCalls) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for mock to be called ${expectedCalls} time(s)`);
}
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
file: { create: options.fileCreate },
message: { create: options.messageCreate },
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
describe("file delivery path resolution", () => {
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(root, "README.md"), "# root readme\n");
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
path: join(root, "README.md"),
name: "README.md",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("resolves a bare build filename against workspace/build", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
const pdf = join(workspace, "build", "student.pdf");
await writeFile(pdf, "pdf bytes");
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
path: pdf,
name: "student.pdf",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not infer files from natural-language prompts", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function makeRepo(): Promise<string> {
const root = await mkdir(join(tmpdir(), `hub-file-delivery-${Date.now()}-${Math.random().toString(36).slice(2)}`), { recursive: true });
await mkdir(join(root, ".git"));
await mkdir(join(root, "examples", "TH-141", "build"), { recursive: true });
return root;
}
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageBatcher, messageBatchKey } from "../../src/feishu/messageBatcher.js";
describe("MessageBatcher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("flushes a single message after the debounce period", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "hello");
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(24);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["hello"]);
});
it("merges two quick messages with a newline", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "first");
await batcher.enqueue("chat-1", "sender-1", "second");
await vi.advanceTimersByTimeAsync(25);
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max message count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxMessages: 2 });
await batcher.enqueue("chat-1", "sender-1", "first");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "second");
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max character count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxChars: 5 });
await batcher.enqueue("chat-1", "sender-1", "abc");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "d");
expect(flushed).toEqual(["abc\nd"]);
});
it("uses the extended debounce for a chunk near the split threshold", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 10, splitThreshold: 5, extendedDebounceMs: 50 });
await batcher.enqueue("chat-1", "sender-1", "12345");
await vi.advanceTimersByTimeAsync(49);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["12345"]);
});
it("keeps different chat/sender pairs in separate batches", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-1", "sender-2", "two");
await batcher.enqueue("chat-2", "sender-1", "three");
await vi.advanceTimersByTimeAsync(25);
expect(flushed.sort()).toEqual(["one", "three", "two"]);
});
it("flushAll flushes every pending batch", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-2", "sender-2", "two");
await batcher.flushAll();
expect(flushed.sort()).toEqual(["one", "two"]);
});
it("builds keys from chat and sender open ids", () => {
expect(messageBatchKey("chat-1", "ou-1")).toBe("chat-1:ou-1");
});
// Slash-command bypass is owned by trigger.ts, not MessageBatcher. Existing
// trigger integration tests cover /new, /resume, /reset as immediate paths.
});
+255 -32
View File
@@ -1,39 +1,262 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runAgent } from "../../src/agent/runner.js"; import { runAgent } from "../../src/agent/runner.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { readFileTool } from "../../src/agent/workspace.js";
import { createMockModelFactory } from "../integration/helpers.js";
describe("runAgent with AI SDK tool loop", () => { const queryMock = vi.hoisted(() => vi.fn());
it("executes SDK tools and returns the final assistant response", async () => {
const ws = await mkdtemp(join(tmpdir(), "hub-runner-")); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
try { query: queryMock,
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8"); }));
const tools = new ToolRegistry();
tools.register("read_file", readFileTool); const stubPrisma = {
const { modelFactory, models } = createMockModelFactory([ projectAgentLock: { update: async () => ({}) },
{ } as unknown as import("@prisma/client").PrismaClient;
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
function assistantMessage(text: string) {
return {
type: "assistant",
message: {
content: [{ type: "text", text }],
usage: { input_tokens: 3, output_tokens: 5 },
},
};
}
function resultMessage(sessionId: string, costUsd?: number) {
return {
type: "result",
subtype: "success",
session_id: sessionId,
...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}),
};
}
function messages(...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
function abortableMessages(ac: AbortController, ...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
// Simulate the SDK: after yielding, the query blocks until the abort
// signal fires, then throws (the SDK raises AbortError).
if (ac.signal.aborted) throw new Error("aborted");
await new Promise<void>((resolve) => {
ac.signal.addEventListener("abort", () => resolve(), { once: true });
});
throw new Error("aborted");
})();
}
describe("runAgent", () => {
beforeEach(() => {
queryMock.mockReset();
});
it("passes the previous Claude SDK session id through resume", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new")));
const result = await runAgent({
prompt: "再发一次",
model: "anthropic/claude-sonnet-5",
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
resumeSessionId: "sdk-session-old",
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock).toHaveBeenCalledWith({
prompt: "再发一次",
options: expect.objectContaining({
cwd: "/tmp/ws",
model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old",
// ADR-0018: agent execution surface bounded by workspace.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
filesystem: expect.objectContaining({
allowWrite: ["/tmp/ws"],
}),
}),
}),
});
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
expect(call?.options).not.toHaveProperty("resume");
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "审校一下",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
tools: ["read_file", "cph_check", "send_file"],
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
},
});
});
it("disables all SDK tools for an empty role tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "只回答",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
tools: [],
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
const result = await runAgent({
prompt: "算一下成本",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.costUsd).toBe(0.0042);
});
it("passes provider env per run without mutating process env", async () => {
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
},
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0];
expect(call).toMatchObject({
options: {
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
}, },
{ text: "done", finishReason: "stop" }, },
]); });
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
});
const result = await runAgent(modelFactory, tools, { it("persists structured user and assistant messages best-effort", async () => {
prompt: "read lesson.txt", queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
model: "mock-model", const createMessage = vi.fn().mockResolvedValue({});
project: { projectId: "p", boundChatId: "c", workspaceDir: ws }, const prisma = {
systemPrompt: "system", projectAgentLock: { update: async () => ({}) },
maxIterations: 5, agentMessage: { create: createMessage },
}); } as unknown as import("@prisma/client").PrismaClient;
expect(result.status).toBe("completed"); await runAgent({
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" }); prompt: "你好",
expect(models.get("mock-model")?.calls).toHaveLength(2); model: undefined,
} finally { project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
await rm(ws, { recursive: true, force: true }); systemPrompt: undefined,
} runId: "run-1",
sessionId: "hub-session-1",
prisma,
});
expect(createMessage).toHaveBeenCalledTimes(2);
expect(createMessage).toHaveBeenNthCalledWith(1, {
data: expect.objectContaining({
sessionId: "hub-session-1",
runId: "run-1",
role: "user",
content: "你好",
}),
});
expect(createMessage).toHaveBeenNthCalledWith(2, {
data: expect.objectContaining({
sessionId: "hub-session-1",
runId: "run-1",
role: "assistant",
content: "ok",
}),
});
});
it("returns interrupted status when the abort controller fires", async () => {
const ac = new AbortController();
queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort")));
const runPromise = runAgent({
prompt: "长任务",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-abort",
sessionId: "hub-session-abort",
prisma: stubPrisma,
abortController: ac,
});
// Let the query yield its messages and block on the abort wait.
await Promise.resolve();
ac.abort();
const result = await runPromise;
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } });
expect(result.error).toBeUndefined();
expect(result.sdkSessionId).toBe("sdk-session-abort");
const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined;
expect(call?.options?.abortController).toBe(ac);
}); });
}); });
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { createEnvRuntimeSettings } from "../../src/settings/runtime.js";
describe("runtime settings", () => {
it("reads the model registry from the current env at call time", async () => {
const env: Record<string, string | undefined> = {
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
};
const settings = createEnvRuntimeSettings(env);
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "z-ai/glm-4.7";
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
});
it("returns provider SDK env without requiring global process env mutation", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
id: "openrouter",
baseUrl: "https://example.test/api",
authToken: "openrouter-token",
anthropicApiKey: "explicit-api-key",
sdkEnv: {
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
},
});
});
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
baseUrl: "https://openrouter.ai/api",
anthropicApiKey: "",
sdkEnv: {
ANTHROPIC_API_KEY: "",
},
});
});
it("requires an auth token for the OpenRouter provider", async () => {
const settings = createEnvRuntimeSettings({});
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
});
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
const settings = createEnvRuntimeSettings({
HUB_AGENT_MAX_TURNS: "9",
});
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 });
});
});
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
describe("SenderNameCache", () => {
afterEach(() => {
vi.useRealTimers();
});
it("get returns null for missing key", () => {
const cache = new SenderNameCache();
expect(cache.get("ou_user")).toBeNull();
});
it("set then get returns the name", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
expect(cache.get("ou_user")).toBe("Alice");
});
it("expired entry returns null", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const cache = new SenderNameCache({ ttlMs: 100 });
cache.set("ou_user", "Alice");
vi.advanceTimersByTime(101);
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("getOrFetch uses cache on hit", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Fetched Alice");
cache.set("ou_user", "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).not.toHaveBeenCalled();
});
it("getOrFetch calls fetchFn on miss", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledWith("ou_user");
});
it("getOrFetch caches the result of fetchFn", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("evicts the least recently used entry when full", () => {
const cache = new SenderNameCache({ maxSize: 2 });
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.get("ou_1")).toBe("Alice");
cache.set("ou_3", "Carol");
expect(cache.get("ou_1")).toBe("Alice");
expect(cache.get("ou_2")).toBeNull();
expect(cache.get("ou_3")).toBe("Carol");
});
it("clear empties the cache", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
cache.clear();
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("size returns correct count", () => {
const cache = new SenderNameCache();
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.size()).toBe(2);
});
it("getOrFetch with null fetchFn result does not cache null", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => null);
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(cache.size()).toBe(0);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
signOAuthState,
signSession,
verifyOAuthState,
verifySession,
} from "../../src/admin/auth/session.js";
import { sanitizeReturnTo } from "../../src/admin/routes/authRoutes.js";
const SECRET = "test-session-secret-not-for-production";
describe("session cookie signing", () => {
it("round-trips a valid session", () => {
const token = signSession(
{ userId: "u1", feishuOpenId: "ou_1" },
SECRET,
3600,
1_700_000_000,
);
const payload = verifySession(token, SECRET, 1_700_000_100);
expect(payload).toEqual({
userId: "u1",
feishuOpenId: "ou_1",
iat: 1_700_000_000,
exp: 1_700_003_600,
});
});
it("rejects tampered tokens", () => {
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
const [body] = token.split(".");
expect(verifySession(`${body}.deadbeef`, SECRET)).toBeNull();
});
it("rejects expired sessions", () => {
const token = signSession(
{ userId: "u1", feishuOpenId: "ou_1" },
SECRET,
10,
1_700_000_000,
);
expect(verifySession(token, SECRET, 1_700_000_011)).toBeNull();
});
it("rejects wrong secret", () => {
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
expect(verifySession(token, "other-secret")).toBeNull();
});
});
describe("oauth state signing", () => {
it("round-trips state with returnTo", () => {
const token = signOAuthState(
{ nonce: "abc", returnTo: "/admin/org/acme" },
SECRET,
600,
1_700_000_000,
);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "abc",
returnTo: "/admin/org/acme",
exp: 1_700_000_600,
});
});
});
describe("sanitizeReturnTo", () => {
it("allows admin paths", () => {
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme");
});
it("blocks open redirects", () => {
expect(sanitizeReturnTo("https://evil.example/")).toBe("/admin");
expect(sanitizeReturnTo("//evil.example")).toBe("/admin");
expect(sanitizeReturnTo("/api/me")).toBe("/admin");
expect(sanitizeReturnTo("")).toBe("/admin");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js";
describe("slash command parser", () => {
it("parses a slash invocation without resolving it", () => {
expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] });
expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] });
expect(parseSlashInvocation("写教案")).toBeNull();
});
it("parses help subcommands only in the exact /<command> help form", () => {
expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new");
expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown");
expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull();
expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { splitAtBoundary } from "../../src/feishu/textStream.js";
describe("splitAtBoundary", () => {
it("keeps short text in a single chunk", () => {
expect(splitAtBoundary("short text", 20)).toEqual(["short text"]);
});
it("keeps text at exactly maxLength in a single chunk", () => {
const text = "x".repeat(20);
expect(splitAtBoundary(text, 20)).toEqual([text]);
});
it("splits text exceeding maxLength into chunks at or below maxLength", () => {
const chunks = splitAtBoundary("x".repeat(45), 20);
expect(chunks).toHaveLength(3);
expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true);
expect(chunks.join("")).toBe("x".repeat(45));
});
it("moves a split before a fenced code block when the boundary lands inside it", () => {
const prefix = "Before code starts\n";
const codeBlock = "```ts\nx\n```\n";
const text = `${prefix}${codeBlock}After`;
const chunks = splitAtBoundary(text, prefix.length + 4);
expect(chunks).toEqual([prefix, `${codeBlock}After`]);
});
it("prefers paragraph boundaries over newline boundaries", () => {
const text = "para one\n\nline two\nline three";
expect(splitAtBoundary(text, 22)).toEqual(["para one\n\n", "line two\nline three"]);
});
it("prefers newline boundaries over space boundaries", () => {
const text = "alpha beta\ngamma delta epsilon";
expect(splitAtBoundary(text, 24)).toEqual(["alpha beta\n", "gamma delta epsilon"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { PatchableTextStream } from "../../src/feishu/textStream.js";
describe("PatchableTextStream", () => {
it("creates only one initial message when deltas arrive while create is pending", async () => {
const creates: string[] = [];
const patches: string[] = [];
let resolveCreate: ((id: string) => void) | undefined;
const firstCreate = new Promise<string>((resolve) => {
resolveCreate = resolve;
});
const stream = new PatchableTextStream(
{
create: async (text) => {
creates.push(text);
return firstCreate;
},
patch: async (_messageId, text) => {
patches.push(text);
},
},
{ patchIntervalMs: 0 },
);
stream.append("我目前运行在");
await Promise.resolve();
stream.append(" Claude Sonnet");
resolveCreate?.("msg-1");
await stream.finish("我目前运行在 Claude Sonnet");
expect(creates).toEqual(["我目前运行在"]);
expect(patches.at(-1)).toBe("我目前运行在 Claude Sonnet");
});
it("sends a fallback message when no stream delta arrived", async () => {
const creates: string[] = [];
const stream = new PatchableTextStream({
create: async (text) => {
creates.push(text);
return "msg-1";
},
patch: async () => {},
});
await stream.finish("完整回复");
expect(creates).toEqual(["完整回复"]);
});
});

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