forked from bai/curriculum-project-hub
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 008c8bcd09 | |||
| 683d5674d1 | |||
| 1f8510a47f | |||
| c4f052efa2 | |||
| 87e3d3f990 | |||
| 519ea8144f | |||
| 34e07e229f | |||
| 2b7aa3294f | |||
| 5d315fff21 | |||
| 716101eed0 | |||
| fc5a5365d8 | |||
| fc0df7b823 | |||
| f1d29176d3 | |||
| f260195439 | |||
| d01ea0e1cb | |||
| d6724e5a83 | |||
| 346aee5a68 | |||
| 491fd13ca8 | |||
| 79505e6d44 | |||
| a0df8b6bcf | |||
| 36c0801e6f | |||
| 4479f88f4f | |||
| 2736006262 | |||
| 294d51dbfe | |||
| bef10149a9 | |||
| 4736610cf3 | |||
| ad65d93b2e | |||
| 6227e1931b | |||
| a00e4f9871 | |||
| a025d23af8 | |||
| dfcc2d70f8 | |||
| 3f486232a8 | |||
| a766d99573 | |||
| 1cfda3ccd2 | |||
| ead5cf04d6 | |||
| ecbc2c8666 | |||
| 5b7fd70124 | |||
| 6178664696 | |||
| c73b41e1da | |||
| 012c8a7d89 | |||
| 250f918346 | |||
| 30d20421b3 | |||
| 20bf7e271f | |||
| eaa7fd4fd8 | |||
| 1ad78dbcce | |||
| 085f7c7186 | |||
| b9f50407be | |||
| 082562ca08 | |||
| 4bd5b03bb1 | |||
| 1abcc495f4 | |||
| e7924f78d5 | |||
| f8c3e16a52 | |||
| a2c8fa8eaf | |||
| afaf5bee09 | |||
| 0fe6cabc68 | |||
| e60aa43731 | |||
| 09cadd0148 | |||
| ce767e9cab | |||
| a4d52e1850 | |||
| 313e890b6c | |||
| b4dd8f09e1 | |||
| a08c33205b | |||
| 3bca137b48 | |||
| 18aac1ff16 |
@@ -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 }}
|
||||||
@@ -11,5 +11,8 @@
|
|||||||
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
|
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
|
||||||
render/vendor/local-packages/
|
render/vendor/local-packages/
|
||||||
|
|
||||||
|
# Node (hub/ TS workspace and any future JS package)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The original ADR-0017 mandated a provider-agnostic agent layer: any
|
||||||
|
OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
|
||||||
|
SDK's `streamText`. The motivation was to avoid vendor lock-in.
|
||||||
|
|
||||||
|
In practice, the hand-rolled agent loop lacked capabilities that a production
|
||||||
|
agent needs: context compaction (long conversations), tool approval flows,
|
||||||
|
partial-message streaming, and a battle-tested multi-turn loop. Building these
|
||||||
|
ourselves meant re-implementing what the Claude Code SDK already provides.
|
||||||
|
|
||||||
|
The Feishu integration also proved costly to hand-roll — card schema quirks,
|
||||||
|
patch limitations, file support — each API quirk cost a round-trip.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
|
||||||
|
OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
|
||||||
|
|
||||||
|
OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
|
||||||
|
SDK speaks its native protocol directly to OpenRouter — no proxy, no format
|
||||||
|
conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
|
||||||
|
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
|
||||||
|
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
|
||||||
|
|
||||||
|
The Hub `AgentSession` is provider/role/model-bound, and it must persist the
|
||||||
|
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
|
||||||
|
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
|
||||||
|
`claudeSessionId` and pass it back to the next `query()` call as
|
||||||
|
`options.resume`. Role is part of the session binding because role prompts and
|
||||||
|
tool surfaces can differ even when the underlying model is the same; `/draft`
|
||||||
|
and `/review` must not resume the same Claude runtime cursor by accident.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
```
|
||||||
|
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||||
|
ANTHROPIC_AUTH_TOKEN=<OpenRouter API key>
|
||||||
|
ANTHROPIC_API_KEY="" # must be explicitly empty
|
||||||
|
```
|
||||||
|
|
||||||
|
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model
|
||||||
|
IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
|
||||||
|
limited OpenRouter-level model routing, but the runtime is still Claude Agent
|
||||||
|
SDK-shaped: non-Claude models are best-effort and may not support every
|
||||||
|
Claude Code feature.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
|
||||||
|
Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
|
||||||
|
Agent SDK-bound.
|
||||||
|
- The agent loop is Claude Code SDK's (compaction, tool approval, partial
|
||||||
|
message streaming) — not hand-rolled.
|
||||||
|
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
|
||||||
|
the SDK's built-in Read/Write/Bash/Glob/Grep.
|
||||||
|
- The SSE event format matches Claude-to-IM's expected protocol natively.
|
||||||
|
- Per-run model selection works via role→model mapping; models are OpenRouter
|
||||||
|
IDs, not Anthropic-only aliases.
|
||||||
|
- OpenRouter recommends setting Anthropic as the top-priority provider for
|
||||||
|
Claude Code; non-Anthropic models may have reduced compatibility with some
|
||||||
|
SDK features (e.g. thinking blocks).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello, World!
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Hub runtime configuration. Copy to .env and fill in.
|
||||||
|
|
||||||
|
# PostgreSQL connection string.
|
||||||
|
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||||
|
|
||||||
|
# Claude Code SDK auth via OpenRouter's Anthropic Skin.
|
||||||
|
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
|
||||||
|
# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
|
||||||
|
ANTHROPIC_BASE_URL="https://openrouter.ai/api"
|
||||||
|
ANTHROPIC_AUTH_TOKEN=""
|
||||||
|
ANTHROPIC_API_KEY=""
|
||||||
|
|
||||||
|
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
|
||||||
|
# Use an OpenRouter model slug, for example:
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL="~anthropic/claude-sonnet-latest"
|
||||||
|
# To use GLM:
|
||||||
|
# ANTHROPIC_DEFAULT_SONNET_MODEL="z-ai/glm-4.7"
|
||||||
|
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
|
||||||
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
|
||||||
|
|
||||||
|
# Agent run policy (optional). Defaults to 25.
|
||||||
|
# HUB_AGENT_MAX_TURNS=25
|
||||||
|
|
||||||
|
# System-managed root for project workspaces created from org admin / Feishu onboarding.
|
||||||
|
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces"
|
||||||
|
|
||||||
|
# Feishu (lark) bot credentials.
|
||||||
|
FEISHU_APP_ID=""
|
||||||
|
FEISHU_APP_SECRET=""
|
||||||
|
FEISHU_BOT_OPEN_ID=""
|
||||||
|
|
||||||
|
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
|
||||||
|
# CPH_BIN="/usr/local/bin/cph"
|
||||||
|
|
||||||
|
# Hub server port. Defaults to 8788.
|
||||||
|
PORT=8788
|
||||||
|
|
||||||
|
# --- Org admin web (ADR-0021) ---------------------------------------------
|
||||||
|
# Public base URL of this Hub (no trailing slash). Used for Feishu OAuth
|
||||||
|
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
|
||||||
|
# Configure the same callback URL in the Feishu developer console under
|
||||||
|
# Security Settings → Redirect URLs.
|
||||||
|
HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788"
|
||||||
|
|
||||||
|
# HMAC secret for signed session + OAuth state cookies. Generate with:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
HUB_SESSION_SECRET=""
|
||||||
|
|
||||||
|
# Optional OAuth scope (space-separated). Default: contact:user.base:readonly
|
||||||
|
# Apply matching scopes in the Feishu developer console.
|
||||||
|
# HUB_OAUTH_SCOPE="contact:user.base:readonly"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Curriculum Project Hub (Feishu agent + cph)
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__SERVICE_USER__
|
||||||
|
WorkingDirectory=__HUB_DIR__
|
||||||
|
EnvironmentFile=__ENV_FILE__
|
||||||
|
# Run prisma migrations before each start, then launch the Hub.
|
||||||
|
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
|
||||||
|
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Graceful shutdown: lark ws close + in-flight runs.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
|
||||||
|
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
|
||||||
|
AssertPathExists=/usr/bin/bwrap
|
||||||
|
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
|
||||||
|
# they would break cph's render-cache extraction (~/.cache/cph/) and the
|
||||||
|
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
|
||||||
|
# boundary; systemd hardening is kept minimal to avoid interfering with it.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy the Hub to a host: rsync the repo, install deps, build, restart service.
|
||||||
|
#
|
||||||
|
# Configure these env vars (e.g. in CI secrets):
|
||||||
|
# PLATFORM_DEPLOY_HOST required
|
||||||
|
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
|
||||||
|
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||||
|
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||||
|
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||||
|
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
|
||||||
|
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
|
||||||
|
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
|
||||||
|
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
|
||||||
|
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||||
|
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
|
||||||
|
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
|
||||||
|
PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||||
|
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||||
|
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
|
||||||
|
HUB_DIR="$BASE/hub"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
|
||||||
|
|
||||||
|
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude node_modules --exclude dist --exclude .env \
|
||||||
|
-e "ssh ${SSH_OPTS[*]}" \
|
||||||
|
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||||
|
|
||||||
|
# 2. Install deps + build on the host.
|
||||||
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build"
|
||||||
|
|
||||||
|
# 3. Ensure the service is installed (idempotent), then restart.
|
||||||
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
|
||||||
|
set -euo pipefail
|
||||||
|
if [ \"\$(id -u)\" = \"0\" ]; then
|
||||||
|
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||||
|
systemctl restart cph-hub.service
|
||||||
|
systemctl is-active --quiet cph-hub.service
|
||||||
|
else
|
||||||
|
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||||
|
sudo -n systemctl restart cph-hub.service
|
||||||
|
sudo -n systemctl is-active --quiet cph-hub.service
|
||||||
|
fi
|
||||||
|
for attempt in \$(seq 1 30); do
|
||||||
|
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl --fail --silent --show-error '$HEALTH_URL' >/dev/null
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the cph-hub systemd service on a host. Idempotent.
|
||||||
|
#
|
||||||
|
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
|
||||||
|
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
|
||||||
|
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||||
|
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||||
|
SERVICE_USER="${SERVICE_USER:-cph-hub}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
PORT="${PORT:-8788}"
|
||||||
|
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||||
|
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
if [ -z "$NODE_BIN" ]; then
|
||||||
|
echo "node must be on PATH, or set NODE_BIN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "$HUB_DIR" ]; then
|
||||||
|
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$HUB_DIR/dist/server.js" ]; then
|
||||||
|
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then
|
||||||
|
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Seed the env file if absent. Secrets stay on the server, never in the repo.
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
install -d -m 0700 "$(dirname "$ENV_FILE")"
|
||||||
|
cat >"$ENV_FILE" <<EOF
|
||||||
|
NODE_ENV=production
|
||||||
|
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||||
|
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
|
||||||
|
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
||||||
|
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||||
|
ANTHROPIC_AUTH_TOKEN=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_BOT_OPEN_ID=
|
||||||
|
PORT=$PORT
|
||||||
|
HOST=$HOST
|
||||||
|
EOF
|
||||||
|
chmod 0600 "$ENV_FILE"
|
||||||
|
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render the unit with concrete paths.
|
||||||
|
UNIT=/etc/systemd/system/cph-hub.service
|
||||||
|
TMP_UNIT="$(mktemp)"
|
||||||
|
sed \
|
||||||
|
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||||
|
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
||||||
|
-e "s|__BASE_DIR__|$BASE|g" \
|
||||||
|
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||||
|
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||||
|
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
|
||||||
|
install -m 0644 "$TMP_UNIT" "$UNIT"
|
||||||
|
rm -f "$TMP_UNIT"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable cph-hub.service
|
||||||
|
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
|
||||||
|
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
|
||||||
Generated
+4713
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@paradigm/hub",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"ai": "^7.0.16",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "npm run prisma:migrate && node dist/server.js",
|
||||||
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||||
|
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
||||||
|
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
|
||||||
|
"deploy": "bash deploy/deploy_platform.sh",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"feishuOpenId" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PlatformRoleAssignment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"role" "PlatformRole" NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"workspaceDir" TEXT NOT NULL,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectGroupBinding" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"chatId" TEXT NOT NULL,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AgentSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AgentRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT,
|
||||||
|
"requestedByUserId" TEXT,
|
||||||
|
"entrypoint" "AgentEntrypoint" NOT NULL,
|
||||||
|
"status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"prompt" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"summary" TEXT,
|
||||||
|
"inputTokens" INTEGER,
|
||||||
|
"outputTokens" INTEGER,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"error" TEXT,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectAgentLock" (
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"runId" TEXT NOT NULL,
|
||||||
|
"holderUserId" TEXT,
|
||||||
|
"acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PermissionGrant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"resourceType" "PermissionResourceType" NOT NULL,
|
||||||
|
"resourceId" TEXT NOT NULL,
|
||||||
|
"principal" TEXT NOT NULL,
|
||||||
|
"role" "PermissionRole" NOT NULL,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PermissionSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"resourceType" "PermissionResourceType" NOT NULL,
|
||||||
|
"resourceId" TEXT NOT NULL,
|
||||||
|
"externalShare" TEXT NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"copyDownload" TEXT NOT NULL,
|
||||||
|
"collaboratorMgmt" TEXT NOT NULL,
|
||||||
|
"agentTrigger" TEXT NOT NULL,
|
||||||
|
"agentCancel" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"runId" TEXT,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoleTriggerGrant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"principal" TEXT NOT NULL,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- FeishuEventReceipt: idempotency for inbound ws events (at-least-once redelivery).
|
||||||
|
CREATE TABLE "FeishuEventReceipt" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"eventId" TEXT NOT NULL,
|
||||||
|
"eventType" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "FeishuEventReceipt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "FeishuEventReceipt_eventId_key" ON "FeishuEventReceipt"("eventId");
|
||||||
|
CREATE INDEX "FeishuEventReceipt_eventType_idx" ON "FeishuEventReceipt"("eventType");
|
||||||
|
CREATE INDEX "FeishuEventReceipt_messageId_idx" ON "FeishuEventReceipt"("messageId");
|
||||||
|
CREATE INDEX "FeishuEventReceipt_receivedAt_idx" ON "FeishuEventReceipt"("receivedAt");
|
||||||
|
|
||||||
|
-- AuditEntry: add projectId so audit points before/without a run (permission
|
||||||
|
-- denials, slash commands) are locatable to a project.
|
||||||
|
ALTER TABLE "AuditEntry" ADD COLUMN "projectId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "AuditEntry_projectId_createdAt_idx" ON "AuditEntry"("projectId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
// Prisma schema for Curriculum Project Hub.
|
||||||
|
//
|
||||||
|
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||||
|
// legacy teaching-material-host-service schema, each deliberate:
|
||||||
|
//
|
||||||
|
// - AgentSession is provider/model-bound. Provider runtime cursors such as
|
||||||
|
// Claude SDK `session_id` live in `metadata`, so switching model still means
|
||||||
|
// a new Hub session while same-session runs can resume provider context.
|
||||||
|
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
|
||||||
|
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
|
||||||
|
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||||
|
// user/chat targets into one binding table.
|
||||||
|
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
|
||||||
|
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
|
||||||
|
// completeness OPEN — add states without a schema migration war).
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Platform identity ---------------------------------------------------
|
||||||
|
|
||||||
|
/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
|
||||||
|
/// organization; platform operator access remains outside project roles.
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
status OrganizationStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
memberships OrganizationMembership[]
|
||||||
|
projectSettings OrganizationProjectSettings?
|
||||||
|
folders Folder[]
|
||||||
|
projects Project[]
|
||||||
|
teams Team[]
|
||||||
|
externalDirectoryConnections ExternalDirectoryConnection[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrganizationStatus {
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Org-scoped platform role. Distinct from project PermissionRole and from
|
||||||
|
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
|
||||||
|
model OrganizationMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
userId String
|
||||||
|
role OrganizationMemberRole @default(MEMBER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organizationId, userId, revokedAt])
|
||||||
|
@@index([organizationId, revokedAt])
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrganizationMemberRole {
|
||||||
|
OWNER
|
||||||
|
ADMIN
|
||||||
|
MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
|
||||||
|
/// create projects from unbound chats only when membersCanCreateProjects=true.
|
||||||
|
model OrganizationProjectSettings {
|
||||||
|
organizationId String @id
|
||||||
|
membersCanCreateProjects Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
feishuOpenId String @unique
|
||||||
|
displayName String
|
||||||
|
avatarUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
platformRoles PlatformRoleAssignment[]
|
||||||
|
organizationMemberships OrganizationMembership[]
|
||||||
|
createdProjects Project[] @relation("projectCreator")
|
||||||
|
requestedRuns AgentRun[] @relation("runRequester")
|
||||||
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
|
teamMemberships TeamMembership[]
|
||||||
|
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||||||
|
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||||
|
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||||
|
auditEntries AuditEntry[] @relation("auditActor")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
||||||
|
/// `admin` is the only override path for force-release (spec RequiresAdmin).
|
||||||
|
model PlatformRoleAssignment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
role PlatformRole
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
@@index([role, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlatformRole {
|
||||||
|
ADMIN
|
||||||
|
TEACHER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0019: typed principals for permission grants and actor resolution.
|
||||||
|
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
|
||||||
|
/// principals by their Feishu ids.
|
||||||
|
enum PrincipalType {
|
||||||
|
USER
|
||||||
|
TEAM
|
||||||
|
FEISHU_CHAT
|
||||||
|
FEISHU_DEPARTMENT
|
||||||
|
FEISHU_USER_GROUP
|
||||||
|
APP
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hub-managed teacher team. A team is a first-class permission principal.
|
||||||
|
model Team {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
slug String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
memberships TeamMembership[]
|
||||||
|
externalBindings TeamExternalBinding[]
|
||||||
|
|
||||||
|
@@index([organizationId, archivedAt])
|
||||||
|
@@index([organizationId, slug, archivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
||||||
|
/// TeamExternalBinding; both sources are resolved at authorization time.
|
||||||
|
model TeamMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, userId, revokedAt])
|
||||||
|
@@index([teamId, revokedAt])
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
|
||||||
|
/// the external principal, the actor also resolves to this team.
|
||||||
|
model TeamExternalBinding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, principalType, principalId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
@@index([teamId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally synchronized Feishu external principal membership.
|
||||||
|
model ExternalDirectoryConnection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
provider String
|
||||||
|
providerTenantId String?
|
||||||
|
source String
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
memberships ExternalPrincipalMembership[]
|
||||||
|
|
||||||
|
@@unique([organizationId, provider, source])
|
||||||
|
@@index([organizationId, revokedAt])
|
||||||
|
@@index([provider, providerTenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally synchronized Feishu external principal membership, scoped through
|
||||||
|
/// an organization-owned ExternalDirectoryConnection.
|
||||||
|
model ExternalPrincipalMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
connectionId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, principalType, principalId, connectionId, revokedAt])
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
@@index([connectionId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||||
|
|
||||||
|
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
|
||||||
|
/// navigation/aggregation nodes, not permission resources; project grants stay
|
||||||
|
/// attached to PROJECT resources.
|
||||||
|
model Folder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
parentId String?
|
||||||
|
name String
|
||||||
|
sortKey String @default("")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
|
children Folder[] @relation("folderTree")
|
||||||
|
projects Project[]
|
||||||
|
|
||||||
|
@@index([organizationId, parentId, archivedAt])
|
||||||
|
@@index([organizationId, parentId, sortKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
folderId String?
|
||||||
|
name String
|
||||||
|
workspaceDir String
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||||
|
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
groupBindings ProjectGroupBinding[]
|
||||||
|
agentSessions AgentSession[]
|
||||||
|
agentRuns AgentRun[]
|
||||||
|
agentLock ProjectAgentLock?
|
||||||
|
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||||
|
auditEntries AuditEntry[] @relation("projectAudit")
|
||||||
|
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||||
|
|
||||||
|
@@index([organizationId, archivedAt])
|
||||||
|
@@index([folderId, archivedAt])
|
||||||
|
@@index([archivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
|
||||||
|
/// (1:1). Historical archived bindings are retained for audit; partial unique
|
||||||
|
/// indexes in migrations enforce one active binding per project and per chat.
|
||||||
|
model ProjectGroupBinding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
chatId String
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([projectId, archivedAt])
|
||||||
|
@@index([chatId, archivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||||
|
|
||||||
|
/// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
|
||||||
|
/// `model` capture the binding; provider-specific runtime cursors live in
|
||||||
|
/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
|
||||||
|
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
|
||||||
|
model AgentSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
provider String
|
||||||
|
roleId String
|
||||||
|
model String
|
||||||
|
title String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
runs AgentRun[]
|
||||||
|
messages AgentMessage[] @relation("sessionMessages")
|
||||||
|
|
||||||
|
@@index([projectId, archivedAt])
|
||||||
|
@@index([provider, roleId, model])
|
||||||
|
@@index([projectId, provider, roleId, model, archivedAt])
|
||||||
|
@@index([updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
|
||||||
|
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
|
||||||
|
/// spec breach. DB mirrors the current enum.
|
||||||
|
enum AgentRunStatus {
|
||||||
|
ACTIVE
|
||||||
|
WAITING_FOR_USER
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
TIMED_OUT
|
||||||
|
CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentEntrypoint {
|
||||||
|
FEISHU
|
||||||
|
WEB
|
||||||
|
CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
model AgentRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
sessionId String?
|
||||||
|
requestedByUserId String?
|
||||||
|
entrypoint AgentEntrypoint
|
||||||
|
status AgentRunStatus @default(ACTIVE)
|
||||||
|
prompt String
|
||||||
|
model String
|
||||||
|
provider String
|
||||||
|
summary String?
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
|
||||||
|
costUsd Decimal? @db.Decimal(18, 8)
|
||||||
|
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
|
||||||
|
costSource String?
|
||||||
|
metadata Json
|
||||||
|
error String?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||||
|
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
projectLock ProjectAgentLock?
|
||||||
|
messages AgentMessage[] @relation("runMessages")
|
||||||
|
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||||
|
|
||||||
|
@@index([projectId, status])
|
||||||
|
@@index([projectId, finishedAt])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([requestedByUserId])
|
||||||
|
@@index([updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
|
||||||
|
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
|
||||||
|
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||||
|
/// app-level invariant checked on read/write, not a DB constraint.
|
||||||
|
model ProjectAgentLock {
|
||||||
|
projectId String @id
|
||||||
|
runId String @unique
|
||||||
|
holderUserId String?
|
||||||
|
acquiredAt DateTime @default(now())
|
||||||
|
heartbeatAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||||
|
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission grants & settings (ADR-0004) ----------------------------
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
|
||||||
|
/// Distinct from PlatformRole. Force-release is admin-only, outside this
|
||||||
|
/// lattice (spec RequiresAdmin).
|
||||||
|
enum PermissionRole {
|
||||||
|
READ
|
||||||
|
EDIT
|
||||||
|
MANAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 resource_type: project | artifact | project_group. The resource
|
||||||
|
/// id's meaning is determined by its type (artifact id semantics align to the
|
||||||
|
/// Courseware half; OPEN here).
|
||||||
|
enum PermissionResourceType {
|
||||||
|
PROJECT
|
||||||
|
ARTIFACT
|
||||||
|
PROJECT_GROUP
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||||
|
/// ADR-0019 replaces the old opaque `principal` with typed
|
||||||
|
/// `principalType/principalId` so user/team/Feishu principals compose through
|
||||||
|
/// the same authorization path.
|
||||||
|
model PermissionGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
resourceType PermissionResourceType
|
||||||
|
resourceId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
role PermissionRole
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([resourceType, resourceId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||||
|
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
|
||||||
|
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
||||||
|
model PermissionSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
resourceType PermissionResourceType
|
||||||
|
resourceId String
|
||||||
|
externalShare String
|
||||||
|
comment String
|
||||||
|
copyDownload String
|
||||||
|
collaboratorMgmt String
|
||||||
|
agentTrigger String
|
||||||
|
agentCancel String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([resourceType, resourceId])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||||||
|
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||||||
|
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
||||||
|
/// /review vs /draft). Two gates in series — both must pass.
|
||||||
|
///
|
||||||
|
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
||||||
|
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
|
||||||
|
/// row grants the role on that project;
|
||||||
|
/// the compound unique covers "one active grant per (project, principal, role)".
|
||||||
|
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
||||||
|
model RoleTriggerGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
roleId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
createdByUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([projectId, roleId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||||
|
|
||||||
|
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
||||||
|
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
|
||||||
|
model AuditEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
runId String?
|
||||||
|
projectId String?
|
||||||
|
actorUserId String?
|
||||||
|
action String
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||||
|
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([runId])
|
||||||
|
@@index([projectId, createdAt])
|
||||||
|
@@index([actorUserId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Feishu event dedup --------------------------------------------------
|
||||||
|
|
||||||
|
/// Idempotency receipt for inbound Feishu ws events. The lark ws client may
|
||||||
|
/// redeliver an event (at-least-once); without dedup a duplicate would spawn a
|
||||||
|
/// second AgentRun — the lock would refuse it, but a FAILED run row + a busy
|
||||||
|
/// reply would still leak. `eventId @unique` makes the second insert a no-op
|
||||||
|
/// signal: the handler checks existence before processing.
|
||||||
|
model FeishuEventReceipt {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventId String @unique
|
||||||
|
eventType String
|
||||||
|
messageId String?
|
||||||
|
receivedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([eventType])
|
||||||
|
@@index([messageId])
|
||||||
|
@@index([receivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Structured run history (ADR-0003 Memory) ----------------------------
|
||||||
|
|
||||||
|
/// Per-message record of the agent loop. The transcript JSONL file remains the
|
||||||
|
/// agent's own read-back memory (runner loads it to seed the next run); these
|
||||||
|
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
|
||||||
|
/// session messages ≠ Feishu group chat history.
|
||||||
|
model AgentMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
runId String?
|
||||||
|
role String
|
||||||
|
content String
|
||||||
|
attachments Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([sessionId, createdAt])
|
||||||
|
@@index([runId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File change captured during a run (writeFile/rename/delete in workspace).
|
||||||
|
/// before/after hash + diff for audit and admin review. Linked to the run
|
||||||
|
/// that produced it; the tool's execute closure records via a sink the runner
|
||||||
|
/// injects into ToolContext (A-3 file-change capture).
|
||||||
|
model AgentFileChange {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
runId String
|
||||||
|
projectId String
|
||||||
|
path String
|
||||||
|
operation String
|
||||||
|
beforeHash String?
|
||||||
|
afterHash String?
|
||||||
|
diff String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([runId])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([path])
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* `cph` subprocess tools - the Hub<->Courseware coupling point.
|
||||||
|
*
|
||||||
|
* The Hub agent does not parse engineering-file models in-process; it calls the
|
||||||
|
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
|
||||||
|
* project; `cph build` renders a target artifact. Version compatibility is the
|
||||||
|
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
|
||||||
|
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
|
||||||
|
* subprocess and returns its stdout/stderr/exit to the model.
|
||||||
|
*
|
||||||
|
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
|
||||||
|
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
|
||||||
|
* diagnostics are relative to the project root.
|
||||||
|
*/
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface ExecResult {
|
||||||
|
readonly exitCode: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
|
||||||
|
cwd: workspaceDir,
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return { exitCode: 0, stdout, stderr };
|
||||||
|
} catch (e) {
|
||||||
|
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
|
||||||
|
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
exitCode: 127,
|
||||||
|
stdout: "",
|
||||||
|
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: typeof err.code === "number" ? err.code : 1,
|
||||||
|
stdout: err.stdout ?? "",
|
||||||
|
stderr: err.stderr ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
|
||||||
|
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
|
||||||
|
/// the file has error diagnostics (ADR-0010); the output is still returned,
|
||||||
|
/// not thrown - the model needs to see the diagnostics to fix them.
|
||||||
|
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
const target = args.path ?? ".";
|
||||||
|
const res = await runCph(["check", target], ctx.workspaceDir);
|
||||||
|
return JSON.stringify({
|
||||||
|
exitCode: res.exitCode,
|
||||||
|
stdout: res.stdout,
|
||||||
|
stderr: res.stderr,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
|
||||||
|
/// target (student/teacher/...) determines the build (ADR-0009). Output path
|
||||||
|
/// is relative to the workspace unless absolute.
|
||||||
|
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
|
||||||
|
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||||
|
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
const target = args.path ?? ".";
|
||||||
|
const out = args.output ?? `build/${args.target}.pdf`;
|
||||||
|
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
|
||||||
|
return JSON.stringify({
|
||||||
|
exitCode: res.exitCode,
|
||||||
|
stdout: res.stdout,
|
||||||
|
stderr: res.stderr,
|
||||||
|
output: out,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
createDefaultModelRegistry,
|
||||||
|
defaultSonnetModel,
|
||||||
|
type Env,
|
||||||
|
} from "../settings/runtime.js";
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Per-run model selection & role-based routing.
|
||||||
|
*
|
||||||
|
* ADR-0017 consequence: role-based model routing (different run kinds default
|
||||||
|
* to different models) is a **product/admin configuration** concern, not a spec
|
||||||
|
* invariant. This registry is the seam for that config; the concrete policy
|
||||||
|
* (which models are admin-enabled, which role maps to which default) is `OPEN`
|
||||||
|
* here — decided by admin settings + ADR, not hard-coded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named role preset — the full per-run agent bundle. Roles are **data**, not
|
||||||
|
* a code enum: admin/teachers define them (ADR-0017: role-based routing is
|
||||||
|
* product config, not a spec invariant). `roleId` is an opaque string and
|
||||||
|
* doubles as the slash-command name (`/draft ...`) — the registry holds the
|
||||||
|
* role set, so new roles are added by configuration, not by editing code.
|
||||||
|
*
|
||||||
|
* A role bundles everything that distinguishes one agent persona from another:
|
||||||
|
* model, system prompt, and the tool surface (files / cph / feishu / skills /
|
||||||
|
* mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and
|
||||||
|
* the cph_hub MCP server builder; security does not rely on the system prompt.
|
||||||
|
*/
|
||||||
|
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||||
|
|
||||||
|
export interface RoleEntry {
|
||||||
|
readonly id: string;
|
||||||
|
/** Human label for the teacher-side switcher UX. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Default model id for runs under this role, if a routing rule is set. */
|
||||||
|
readonly defaultModel: string | undefined;
|
||||||
|
/**
|
||||||
|
* System prompt seeding the agent's persona/instructions. Prepended to the
|
||||||
|
* run's messages only at session start; Claude SDK resume restores later
|
||||||
|
* turns from the provider session. `undefined` ⇒ no system prompt (bare run).
|
||||||
|
*/
|
||||||
|
readonly systemPrompt?: string | undefined;
|
||||||
|
/**
|
||||||
|
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
|
||||||
|
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
|
||||||
|
* Invalid names fail fast when settings are loaded or the run is set up.
|
||||||
|
*/
|
||||||
|
readonly tools?: readonly string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model the admin has enabled for use by the Hub. */
|
||||||
|
export interface ModelEntry {
|
||||||
|
readonly id: string;
|
||||||
|
/** Human label for the teacher-side switcher UX. */
|
||||||
|
readonly label: string;
|
||||||
|
/** True when the model is known to support tool use reliably. */
|
||||||
|
readonly toolCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRegistry {
|
||||||
|
/** All models admin-enabled for this Hub instance. */
|
||||||
|
listModels(): readonly ModelEntry[];
|
||||||
|
/** All role presets currently configured (admin/teacher-defined). */
|
||||||
|
listRoles(): readonly RoleEntry[];
|
||||||
|
/** The role preset with the given id, if any. */
|
||||||
|
role(id: string): RoleEntry | undefined;
|
||||||
|
/** Resolve a model id: explicit request → role default → first enabled. */
|
||||||
|
resolve(requestedModel: string | undefined, roleId: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
|
||||||
|
* is the skeleton seam. Both the model list and the role set are pluggable data
|
||||||
|
* — adding a role is a config change, not a code change.
|
||||||
|
*/
|
||||||
|
export class InMemoryModelRegistry implements ModelRegistry {
|
||||||
|
private readonly models: readonly ModelEntry[];
|
||||||
|
private readonly roles: Map<string, RoleEntry>;
|
||||||
|
|
||||||
|
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
|
||||||
|
this.models = models;
|
||||||
|
for (const role of roles) {
|
||||||
|
if (role.tools !== undefined) assertSupportedRoleTools(role.tools);
|
||||||
|
}
|
||||||
|
this.roles = new Map(roles.map((r) => [r.id, r]));
|
||||||
|
}
|
||||||
|
|
||||||
|
listModels(): readonly ModelEntry[] {
|
||||||
|
return this.models;
|
||||||
|
}
|
||||||
|
|
||||||
|
listRoles(): readonly RoleEntry[] {
|
||||||
|
return [...this.roles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
role(id: string): RoleEntry | undefined {
|
||||||
|
return this.roles.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a model id for a run. Priority: teacher's explicit request → the
|
||||||
|
* role preset's default → the first admin-enabled model. `roleId` is a string
|
||||||
|
* so unknown roles degrade gracefully to the fallback rather than throwing.
|
||||||
|
*/
|
||||||
|
resolve(requestedModel: string | undefined, roleId: string): string {
|
||||||
|
if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) {
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
const role = this.roles.get(roleId);
|
||||||
|
if (role?.defaultModel !== undefined) return role.defaultModel;
|
||||||
|
const first = this.models[0];
|
||||||
|
if (first === undefined) throw new Error("no models enabled for this Hub");
|
||||||
|
return first.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Claude Code SDK agent runner.
|
||||||
|
*
|
||||||
|
* Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
|
||||||
|
* owns tool dispatch, context compaction, streaming — we just map its events
|
||||||
|
* to the Feishu streaming callback.
|
||||||
|
*
|
||||||
|
* Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
|
||||||
|
* - read_file → Read
|
||||||
|
* - write_file → Write
|
||||||
|
* - list_files → Glob
|
||||||
|
* - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
|
||||||
|
* No custom tools needed — the SDK's Bash tool runs cph directly.
|
||||||
|
*
|
||||||
|
* ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
|
||||||
|
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
|
||||||
|
* message streaming, and a battle-tested agent loop — capabilities we'd have
|
||||||
|
* to build ourselves with a generic provider.
|
||||||
|
*
|
||||||
|
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||||
|
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||||
|
* Code process and its subprocesses at the OS level — writes are confined to
|
||||||
|
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
|
||||||
|
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
|
||||||
|
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
|
||||||
|
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||||
|
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||||
|
* is the mechanism, the contract pins the invariant.
|
||||||
|
*/
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { claudeSdkToolConfigForRole } from "./roleTools.js";
|
||||||
|
|
||||||
|
export interface ProjectContext {
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly boundChatId: string;
|
||||||
|
readonly workspaceDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| { readonly type: "text-delta"; readonly text: string }
|
||||||
|
| { readonly type: "thinking-delta"; readonly text: string }
|
||||||
|
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
|
||||||
|
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
|
||||||
|
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
|
||||||
|
| { readonly type: "finish" };
|
||||||
|
|
||||||
|
export type StreamCallback = (event: StreamEvent) => void;
|
||||||
|
|
||||||
|
export interface RunRequest {
|
||||||
|
readonly prompt: string;
|
||||||
|
readonly model: string | undefined;
|
||||||
|
readonly project: ProjectContext;
|
||||||
|
readonly systemPrompt: string | undefined;
|
||||||
|
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||||
|
readonly resumeSessionId?: string | undefined;
|
||||||
|
/**
|
||||||
|
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
|
||||||
|
* (for example read_file/cph_check/send_file), mapped to Claude SDK tool
|
||||||
|
* names at run setup. `undefined` means the full supported tool surface; []
|
||||||
|
* means no tools.
|
||||||
|
*/
|
||||||
|
readonly tools?: readonly string[] | undefined;
|
||||||
|
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
||||||
|
readonly maxTurns?: number;
|
||||||
|
readonly runId: string;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly onStream?: StreamCallback;
|
||||||
|
/**
|
||||||
|
* Abort controller for the run. Aborting the signal interrupts the SDK query
|
||||||
|
* (it stops the agent loop and cleans up); the run resolves with status
|
||||||
|
* "interrupted". The caller owns the controller and triggers abort.
|
||||||
|
*/
|
||||||
|
readonly abortController?: AbortController | undefined;
|
||||||
|
}
|
||||||
|
export type RunStatus = "completed" | "length" | "failed" | "interrupted";
|
||||||
|
|
||||||
|
export interface RunResult {
|
||||||
|
readonly status: RunStatus;
|
||||||
|
readonly text: string;
|
||||||
|
readonly usage: { inputTokens: number; outputTokens: number };
|
||||||
|
/** Provider- or gateway-reported cost for this run, in USD. */
|
||||||
|
readonly costUsd?: number | undefined;
|
||||||
|
readonly numTurns: number;
|
||||||
|
readonly sdkSessionId?: string | undefined;
|
||||||
|
readonly error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_TURNS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
|
||||||
|
* confines writes to the workspace; these deny reads of credentials/secrets
|
||||||
|
* the process could otherwise see (read-only mounts are still readable). The
|
||||||
|
* workspace itself is never in this list — it's writable by `allowWrite`.
|
||||||
|
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
|
||||||
|
* with additional secret locations.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
|
||||||
|
const home = homedir();
|
||||||
|
const base = [
|
||||||
|
"/etc",
|
||||||
|
"/root",
|
||||||
|
"/var/log",
|
||||||
|
"/proc",
|
||||||
|
"/sys",
|
||||||
|
`${home}/.ssh`,
|
||||||
|
`${home}/.aws`,
|
||||||
|
`${home}/.config/gcloud`,
|
||||||
|
`${home}/.gnupg`,
|
||||||
|
];
|
||||||
|
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||||
|
return extra !== undefined && extra !== ""
|
||||||
|
? [...base, ...extra.split(":").filter((p) => p !== "")]
|
||||||
|
: base;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||||
|
const onStream = req.onStream;
|
||||||
|
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||||
|
|
||||||
|
// Lock heartbeat: refresh on each step. Best-effort.
|
||||||
|
const heartbeat = async () => {
|
||||||
|
try {
|
||||||
|
await req.prisma.projectAgentLock.update({
|
||||||
|
where: { runId: req.runId },
|
||||||
|
data: { heartbeatAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
let fullText = "";
|
||||||
|
let usage = { inputTokens: 0, outputTokens: 0 };
|
||||||
|
let costUsd: number | undefined;
|
||||||
|
let numTurns = 0;
|
||||||
|
let sdkSessionId: string | undefined;
|
||||||
|
let error: string | undefined;
|
||||||
|
try {
|
||||||
|
await persistAgentMessage(req, "user", req.prompt);
|
||||||
|
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||||
|
|
||||||
|
const options: Parameters<typeof query>[0]["options"] = {
|
||||||
|
cwd: req.project.workspaceDir,
|
||||||
|
tools: [...toolConfig.tools],
|
||||||
|
allowedTools: [...toolConfig.allowedTools],
|
||||||
|
maxTurns: cap,
|
||||||
|
includePartialMessages: true,
|
||||||
|
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||||
|
// below is the hard boundary, not the permission-prompt layer.
|
||||||
|
permissionMode: "bypassPermissions" as const,
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
// ADR-0018: OS-level sandbox confines the agent process + its Bash
|
||||||
|
// subprocesses. Writes confined to the workspace; sensitive host paths
|
||||||
|
// denied reads; network open. failIfUnavailable hard-fails if the
|
||||||
|
// sandbox can't start — never run unsandboxed.
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
failIfUnavailable: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
filesystem: {
|
||||||
|
allowWrite: [req.project.workspaceDir],
|
||||||
|
denyRead: [...SENSITIVE_READ_PATHS],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||||
|
if (req.model !== undefined) options.model = req.model;
|
||||||
|
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
|
||||||
|
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||||
|
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||||
|
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||||
|
|
||||||
|
const conversation = query({
|
||||||
|
prompt: req.prompt,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track tool start timestamps for duration calculation
|
||||||
|
const toolStartTimestamps = new Map<string, number>();
|
||||||
|
|
||||||
|
for await (const message of conversation) {
|
||||||
|
switch (message.type) {
|
||||||
|
case "stream_event": {
|
||||||
|
const evt = (message as SDKPartialAssistantMessage).event;
|
||||||
|
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
|
||||||
|
onStream?.({ type: "text-delta", text: evt.delta.text });
|
||||||
|
}
|
||||||
|
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
|
||||||
|
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
|
||||||
|
}
|
||||||
|
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||||
|
const toolUseId = evt.content_block.id;
|
||||||
|
toolStartTimestamps.set(toolUseId, Date.now());
|
||||||
|
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
|
||||||
|
}
|
||||||
|
if (evt.type === "content_block_stop") {
|
||||||
|
// Tool end is attributed from the assistant message below.
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "assistant": {
|
||||||
|
const msg = (message as SDKAssistantMessage).message;
|
||||||
|
await heartbeat();
|
||||||
|
numTurns++;
|
||||||
|
let assistantText = "";
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === "text" && typeof block.text === "string") {
|
||||||
|
fullText += block.text;
|
||||||
|
assistantText += block.text;
|
||||||
|
}
|
||||||
|
if (block.type === "tool_use") {
|
||||||
|
const durationMs = toolStartTimestamps.has(block.id)
|
||||||
|
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
|
||||||
|
: undefined;
|
||||||
|
onStream?.({
|
||||||
|
type: "tool-end",
|
||||||
|
toolName: block.name,
|
||||||
|
toolUseId: block.id,
|
||||||
|
input: block.input,
|
||||||
|
...(durationMs !== undefined ? { durationMs } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||||
|
// Complete thinking block from the assistant message.
|
||||||
|
// Stream deltas already delivered this; no separate event needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persistAgentMessage(req, "assistant", assistantText);
|
||||||
|
if (msg.usage !== undefined) {
|
||||||
|
usage.inputTokens += msg.usage.input_tokens ?? 0;
|
||||||
|
usage.outputTokens += msg.usage.output_tokens ?? 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "user": {
|
||||||
|
// User messages carry tool_result blocks — what each tool returned.
|
||||||
|
const msg = (message as SDKUserMessage).message;
|
||||||
|
const content = msg.content;
|
||||||
|
if (!Array.isArray(content)) break;
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
|
||||||
|
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
|
||||||
|
if (!("tool_use_id" in block)) continue;
|
||||||
|
const toolUseId = block.tool_use_id;
|
||||||
|
const isError = block.is_error === true;
|
||||||
|
const resultText = extractToolResultText(block.content);
|
||||||
|
const durationMs = toolStartTimestamps.get(toolUseId);
|
||||||
|
onStream?.({
|
||||||
|
type: "tool-result",
|
||||||
|
toolUseId,
|
||||||
|
toolName: toolUseId,
|
||||||
|
result: resultText,
|
||||||
|
isError,
|
||||||
|
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "result": {
|
||||||
|
const result = message as SDKResultMessage;
|
||||||
|
sdkSessionId = result.session_id;
|
||||||
|
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
|
||||||
|
if (result.subtype !== "success") {
|
||||||
|
error = `result_${result.subtype}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStream?.({ type: "finish" });
|
||||||
|
return {
|
||||||
|
status: error !== undefined ? "failed" : "completed",
|
||||||
|
text: fullText,
|
||||||
|
usage,
|
||||||
|
...(costUsd !== undefined ? { costUsd } : {}),
|
||||||
|
numTurns,
|
||||||
|
sdkSessionId,
|
||||||
|
...(error !== undefined ? { error } : {}),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const aborted = req.abortController?.signal.aborted === true;
|
||||||
|
return {
|
||||||
|
status: aborted ? "interrupted" : "failed",
|
||||||
|
text: fullText,
|
||||||
|
usage,
|
||||||
|
...(costUsd !== undefined ? { costUsd } : {}),
|
||||||
|
numTurns,
|
||||||
|
sdkSessionId,
|
||||||
|
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
|
||||||
|
if (content === "") return;
|
||||||
|
try {
|
||||||
|
await req.prisma.agentMessage.create({
|
||||||
|
data: {
|
||||||
|
sessionId: req.sessionId,
|
||||||
|
runId: req.runId,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort projection: a history write failure must not break the run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolResultText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
|
||||||
|
parts.push(part.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Tool registry - the agent's narrow, project-scoped tool surface.
|
||||||
|
*
|
||||||
|
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
|
||||||
|
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
||||||
|
* (ADR-0003). Its tools are therefore bounded:
|
||||||
|
*
|
||||||
|
* - file-tree read/write against the project workspace dir,
|
||||||
|
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
|
||||||
|
* coupled only via the stable CLI contract ADR-0016),
|
||||||
|
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
||||||
|
*
|
||||||
|
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
|
||||||
|
* construction and per-role whitelisting (ADR-0017).
|
||||||
|
*/
|
||||||
|
import { tool, type Tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Per-run execution context closed over by every tool execute function. */
|
||||||
|
export interface ToolContext {
|
||||||
|
readonly runId: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
|
||||||
|
readonly boundChatId: string;
|
||||||
|
/** Absolute path to the project's curriculum engineering-file workspace. */
|
||||||
|
readonly workspaceDir: string;
|
||||||
|
/** Optional sink for file-change capture (A-3). The writeFile tool calls
|
||||||
|
* this with before/after hash when it modifies a file; the runner injects
|
||||||
|
* a collector that flushes to AgentFileChange at run end. Undefined when
|
||||||
|
* the runner doesn't wire capture (e.g. unit tests). */
|
||||||
|
readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolDefinition = Tool;
|
||||||
|
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
|
||||||
|
|
||||||
|
export class ToolRegistry {
|
||||||
|
private readonly factories = new Map<string, ToolFactory>();
|
||||||
|
|
||||||
|
register(name: string, factory: ToolFactory): void {
|
||||||
|
if (this.factories.has(name)) {
|
||||||
|
throw new Error(`duplicate tool: ${name}`);
|
||||||
|
}
|
||||||
|
this.factories.set(name, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the AI SDK `tools` record. If `names` is given, include only those
|
||||||
|
* tools (per-role whitelist, ADR-0017). Names not registered are silently
|
||||||
|
* dropped because role config may name tools a Hub instance does not provide.
|
||||||
|
*/
|
||||||
|
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
|
||||||
|
const built: Record<string, ToolDefinition> = {};
|
||||||
|
const selected = names ?? [...this.factories.keys()];
|
||||||
|
for (const name of selected) {
|
||||||
|
const factory = this.factories.get(name);
|
||||||
|
if (factory !== undefined) {
|
||||||
|
built[name] = factory(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Feishu context read targets a chat other than the run's project's
|
||||||
|
* bound chat. This is the runtime enforcement of ADR-0003's
|
||||||
|
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
|
||||||
|
*/
|
||||||
|
export class UnauthorizedChatRead extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly requestedChatId: string,
|
||||||
|
readonly boundChatId: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
|
||||||
|
);
|
||||||
|
this.name = "UnauthorizedChatRead";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for the Feishu context read tool's arguments. */
|
||||||
|
export interface FeishuContextArgs {
|
||||||
|
readonly chat_id: string;
|
||||||
|
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Feishu context-read tool factory. The execute closure enforces
|
||||||
|
* ADR-0003's invariant before any API call: the requested `chat_id` must equal
|
||||||
|
* `ctx.boundChatId`.
|
||||||
|
*
|
||||||
|
* The actual Feishu API call (read message / reply chain / thread / recent
|
||||||
|
* group messages) is injected as `read`, so the invariant check is separable
|
||||||
|
* from the transport. In tests `read` can be a stub; in production it wraps
|
||||||
|
* `@larksuiteoapi/node-sdk`.
|
||||||
|
*/
|
||||||
|
export function feishuContextTool(
|
||||||
|
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||||
|
rt: unknown,
|
||||||
|
): ToolFactory {
|
||||||
|
return (ctx) =>
|
||||||
|
tool({
|
||||||
|
description:
|
||||||
|
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||||
|
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||||
|
id: z.string().describe("The anchor id (message id or run id)."),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
if (args.chat_id !== ctx.boundChatId) {
|
||||||
|
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
|
||||||
|
}
|
||||||
|
return read(args, ctx, rt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Session transcript — append-only JSONL file in the workspace.
|
||||||
|
*
|
||||||
|
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
|
||||||
|
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
|
||||||
|
* is a directory tree" principle as the engineering file itself (ADR-0007).
|
||||||
|
*
|
||||||
|
* This mirrors how Claude Code stores its transcript (a `.jsonl` file per
|
||||||
|
* session). The agent can read its own history via `read_file` — no special
|
||||||
|
* "load history" code path. ADR-0002's lock guarantees one run per project at a
|
||||||
|
* time, so the transcript is never concurrently written.
|
||||||
|
*
|
||||||
|
* ADR-0003: this stores **agent session messages** (prompt + response + tool
|
||||||
|
* calls produced by the Hub), NOT Feishu group chat history. The two are
|
||||||
|
* distinct; storing session messages does not conflict with "the Hub avoids
|
||||||
|
* becoming a full chat history store."
|
||||||
|
*/
|
||||||
|
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import type { ModelMessage } from "ai";
|
||||||
|
|
||||||
|
/// Subdirectory within a workspace where session transcripts live.
|
||||||
|
export const TRANSCRIPT_DIR = ".cph/sessions";
|
||||||
|
|
||||||
|
/** Path for a session's transcript file within a workspace. */
|
||||||
|
export function transcriptPath(workspaceDir: string, sessionId: string): string {
|
||||||
|
return join(workspaceDir, TRANSCRIPT_DIR, `${sessionId}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read prior messages from a transcript file. Returns an empty array if the
|
||||||
|
* file doesn't exist yet (first run in this session). Corrupt lines are
|
||||||
|
* skipped — a partially-written last line (crash mid-append) won't break the
|
||||||
|
* next run.
|
||||||
|
*/
|
||||||
|
export async function readTranscript(path: string): Promise<ModelMessage[]> {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await readFile(path, "utf8");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const messages: ModelMessage[] = [];
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") continue;
|
||||||
|
try {
|
||||||
|
messages.push(JSON.parse(trimmed) as ModelMessage);
|
||||||
|
} catch {
|
||||||
|
// Skip corrupt line — likely a partial write from a crash.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append messages to a transcript file. Creates the parent directory if needed.
|
||||||
|
* Each message is written as one JSON line.
|
||||||
|
*/
|
||||||
|
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
||||||
|
await appendFile(path, lines, "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Workspace file tools - bounded read/write/list against the project's
|
||||||
|
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||||
|
*
|
||||||
|
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||||
|
* requested relative path against the workspace root and rejects any path that
|
||||||
|
* escapes it (via `..` or absolute paths). This is the agent's only file
|
||||||
|
* surface; the Hub agent is narrower than a general coding agent.
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||||
|
import { tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||||
|
|
||||||
|
/** Thrown when a requested path escapes the project workspace root. */
|
||||||
|
export class PathEscape extends Error {
|
||||||
|
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||||
|
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||||
|
this.name = "PathEscape";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
|
||||||
|
function confine(requestedPath: string, workspaceDir: string): string {
|
||||||
|
if (isAbsolute(requestedPath)) {
|
||||||
|
// Allow absolute paths only if they're already inside the workspace.
|
||||||
|
const rel = relative(workspaceDir, requestedPath);
|
||||||
|
if (rel.startsWith("..") || rel === "") {
|
||||||
|
throw new PathEscape(requestedPath, workspaceDir);
|
||||||
|
}
|
||||||
|
return requestedPath;
|
||||||
|
}
|
||||||
|
const resolved = resolve(workspaceDir, requestedPath);
|
||||||
|
const rel = relative(workspaceDir, resolved);
|
||||||
|
if (rel.startsWith("..")) {
|
||||||
|
throw new PathEscape(requestedPath, workspaceDir);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileTool(ctx: ToolContext): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
path: z.string().describe("Relative path within the workspace."),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const full = confine(args.path, ctx.workspaceDir);
|
||||||
|
return await readFile(full, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
path: z.string().describe("Relative path within the workspace."),
|
||||||
|
content: z.string().describe("The full file content to write."),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const full = confine(args.path, ctx.workspaceDir);
|
||||||
|
// A-3 file-change capture: compute before/after hashes around the write.
|
||||||
|
// A hashing failure must not block the write — skip the sink in that case.
|
||||||
|
let beforeHash: string | null = null;
|
||||||
|
let afterHash: string | null = null;
|
||||||
|
let operation: "CREATE" | "UPDATE" | null = null;
|
||||||
|
try {
|
||||||
|
let oldContent: string | null;
|
||||||
|
try {
|
||||||
|
oldContent = await readFile(full, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
|
||||||
|
oldContent = null;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterHash = createHash("sha256").update(args.content).digest("hex");
|
||||||
|
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
|
||||||
|
if (beforeHash === null) {
|
||||||
|
operation = "CREATE";
|
||||||
|
} else if (beforeHash === afterHash) {
|
||||||
|
operation = null;
|
||||||
|
} else {
|
||||||
|
operation = "UPDATE";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
operation = null;
|
||||||
|
}
|
||||||
|
await mkdir(join(full, ".."), { recursive: true });
|
||||||
|
await writeFile(full, args.content, "utf8");
|
||||||
|
if (operation !== null && ctx.onFileChange !== undefined) {
|
||||||
|
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
|
||||||
|
}
|
||||||
|
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: "file" | "dir";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFilesTool(ctx: ToolContext): ToolDefinition {
|
||||||
|
return tool({
|
||||||
|
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
|
||||||
|
}),
|
||||||
|
execute: async (args): Promise<string> => {
|
||||||
|
const sub = args.path ?? ".";
|
||||||
|
try {
|
||||||
|
const full = confine(sub, ctx.workspaceDir);
|
||||||
|
const entries = await readdir(full, { withFileTypes: true });
|
||||||
|
const result: Entry[] = entries.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
kind: e.isDirectory() ? "dir" : "file",
|
||||||
|
}));
|
||||||
|
return JSON.stringify(result);
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
|
||||||
|
* callers (trigger path, permission denials, slash commands) had no way to
|
||||||
|
* record what happened. This module is that single write seam.
|
||||||
|
*
|
||||||
|
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
|
||||||
|
* event type / actor / timestamp / details are content-OPEN. This helper
|
||||||
|
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
|
||||||
|
* (details) + `projectId` (locates audit points that don't have a run, e.g.
|
||||||
|
* permission denials) + `runId` (loose string ref, not a Prisma relation —
|
||||||
|
* see schema). `createdAt` is `@default(now())` so we don't pass it.
|
||||||
|
*
|
||||||
|
* Best-effort by design: an audit failure must never break the caller's path
|
||||||
|
* (a trigger failing because the audit log is down would be a spec breach —
|
||||||
|
* audit is observability, not control). Errors are swallowed here. There's no
|
||||||
|
* logger in this layer yet; a future logger can be threaded in without
|
||||||
|
* changing the signature.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface AuditInput {
|
||||||
|
readonly runId?: string | undefined;
|
||||||
|
readonly projectId?: string | undefined;
|
||||||
|
readonly actorUserId?: string | undefined;
|
||||||
|
readonly action: string;
|
||||||
|
readonly metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
|
||||||
|
* caller's path (trigger, permission gate, slash command) is unaffected.
|
||||||
|
*
|
||||||
|
* Optional fields are only included when defined — under
|
||||||
|
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
|
||||||
|
* Prisma `create` payload is a type error (and would also write an explicit
|
||||||
|
* NULL rather than omit the column). Conditional assignment keeps the payload
|
||||||
|
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
|
||||||
|
* direct-FK variant) so it matches one branch of Prisma's `create` input
|
||||||
|
* union exactly — the relation-connect branch has no `projectId`/`runId`
|
||||||
|
* columns and would otherwise reject these fields.
|
||||||
|
*/
|
||||||
|
export async function writeAudit(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
entry: AuditInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const data: Prisma.AuditEntryUncheckedCreateInput = {
|
||||||
|
action: entry.action,
|
||||||
|
metadata: entry.metadata as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
if (entry.runId !== undefined) data.runId = entry.runId;
|
||||||
|
if (entry.projectId !== undefined) data.projectId = entry.projectId;
|
||||||
|
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.auditEntry.create({ data });
|
||||||
|
} catch {
|
||||||
|
// Swallow: audit is best-effort and must not break the caller's path.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Prisma client singleton.
|
||||||
|
*
|
||||||
|
* Prisma's client is a connection-pooled datasource; constructing it per-query
|
||||||
|
* exhausts connections. This module exports one shared instance per process.
|
||||||
|
* Hot-reload (tsx watch) can re-import this module — the global guard prevents
|
||||||
|
* a second PrismaClient from being created.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { __hubPrisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma: PrismaClient =
|
||||||
|
globalForPrisma.__hubPrisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env["HUB_PRISMA_LOG"] !== undefined ? ["query", "error", "warn"] : ["error", "warn"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
|
globalForPrisma.__hubPrisma = prisma;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
/**
|
||||||
|
* Feishu lark client + long-connection event dispatcher.
|
||||||
|
*
|
||||||
|
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
|
||||||
|
* message sending, streaming patch via interactive cards, emoji
|
||||||
|
* reactions, file download/upload, and the card action callback.
|
||||||
|
*/
|
||||||
|
import * as lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||||
|
import type { SenderNameCache } from "./senderCache.js";
|
||||||
|
|
||||||
|
export interface FeishuConfig {
|
||||||
|
readonly appId: string;
|
||||||
|
readonly appSecret: string;
|
||||||
|
readonly botOpenId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeishuRuntime {
|
||||||
|
readonly client: lark.Client;
|
||||||
|
readonly logger: FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutboundPayload {
|
||||||
|
readonly msgType: string;
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
readonly replyToMessageId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
maxAttempts?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
shouldRetry?: (error: unknown) => boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
||||||
|
const baseDelayMs = options.baseDelayMs ?? 1000;
|
||||||
|
const shouldRetry = options.shouldRetry ?? (() => true);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxAttempts || !shouldRetry(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const delay = baseDelayMs * 2 ** (attempt - 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("withRetry reached an unreachable state");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw `im.message.receive_v1` event payload. */
|
||||||
|
export interface MessageReceiveEvent {
|
||||||
|
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
|
||||||
|
readonly event_id?: string;
|
||||||
|
readonly event_type?: string;
|
||||||
|
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
||||||
|
readonly message: {
|
||||||
|
readonly message_id: string;
|
||||||
|
readonly root_id?: string;
|
||||||
|
readonly parent_id?: string;
|
||||||
|
readonly thread_id?: string;
|
||||||
|
readonly create_time?: string;
|
||||||
|
readonly update_time?: string;
|
||||||
|
readonly chat_id: string;
|
||||||
|
readonly chat_type: string;
|
||||||
|
readonly message_type: string;
|
||||||
|
readonly content: string;
|
||||||
|
readonly mentions?: ReadonlyArray<{
|
||||||
|
readonly key: string;
|
||||||
|
readonly id: { readonly open_id?: string };
|
||||||
|
readonly name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
readonly sender: {
|
||||||
|
readonly sender_id: { readonly open_id?: string };
|
||||||
|
readonly sender_type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card action trigger event (button clicks, select changes). */
|
||||||
|
export interface CardActionEvent {
|
||||||
|
readonly operator: { readonly open_id?: string };
|
||||||
|
readonly action: {
|
||||||
|
readonly value?: unknown;
|
||||||
|
readonly tag?: string;
|
||||||
|
readonly option?: string;
|
||||||
|
};
|
||||||
|
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
|
||||||
|
readonly token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactBasicUser {
|
||||||
|
readonly name?: string | undefined;
|
||||||
|
readonly i18n_name?: {
|
||||||
|
readonly zh_cn?: string | undefined;
|
||||||
|
readonly en_us?: string | undefined;
|
||||||
|
readonly ja_jp?: string | undefined;
|
||||||
|
} | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactUserBasicBatchResponse = {
|
||||||
|
readonly data?: {
|
||||||
|
readonly users?: readonly ContactBasicUser[] | undefined;
|
||||||
|
} | undefined;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// --- Message helpers ---
|
||||||
|
|
||||||
|
export function buildOutboundPayload(content: string): OutboundPayload {
|
||||||
|
if (containsMarkdownTable(content)) {
|
||||||
|
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
||||||
|
}
|
||||||
|
if (containsMarkdownSyntax(content)) {
|
||||||
|
return {
|
||||||
|
msgType: "post",
|
||||||
|
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _buildMarkdownPostRows(content: string): unknown[][] {
|
||||||
|
if (!content.includes("```")) {
|
||||||
|
return [[{ tag: "md", text: content }]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
const codeBlockPattern = /```[\s\S]*?```/g;
|
||||||
|
let cursor = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = codeBlockPattern.exec(content)) !== null) {
|
||||||
|
if (match.index > cursor) {
|
||||||
|
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
|
||||||
|
}
|
||||||
|
rows.push([{ tag: "md", text: match[0] }]);
|
||||||
|
cursor = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return [[{ tag: "md", text: content }]];
|
||||||
|
}
|
||||||
|
if (cursor < content.length) {
|
||||||
|
rows.push([{ tag: "md", text: content.slice(cursor) }]);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _stripMarkdownToPlainText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
|
||||||
|
.replace(/`([^`\n]+)`/g, "$1")
|
||||||
|
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
|
||||||
|
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
|
||||||
|
.replace(/^ {0,3}#{1,6}\s+/gm, "")
|
||||||
|
.replace(/^ {0,3}>\s?/gm, "")
|
||||||
|
.replace(/^(\s*)[-*+]\s+/gm, "$1")
|
||||||
|
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
|
||||||
|
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
|
||||||
|
.replace(/__([^_\n]+)__/g, "$1")
|
||||||
|
.replace(/~~([^~\n]+)~~/g, "$1")
|
||||||
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
|
||||||
|
.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } {
|
||||||
|
const parsed = parseJsonObject(content);
|
||||||
|
const root = parsed === null ? null : postRoot(parsed);
|
||||||
|
const locale = root === null ? null : selectPostLocale(root);
|
||||||
|
if (locale === null) {
|
||||||
|
return { text: "", imageKeys: [], fileKeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageKeys: string[] = [];
|
||||||
|
const fileKeys: string[] = [];
|
||||||
|
const rows = Array.isArray(locale["content"]) ? locale["content"] : [];
|
||||||
|
const body = rows
|
||||||
|
.map((row) => {
|
||||||
|
const elements = Array.isArray(row) ? row : [row];
|
||||||
|
return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join("");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const title = stringField(locale, "title");
|
||||||
|
const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`;
|
||||||
|
return { text, imageKeys, fileKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a text message using the best Feishu message type for the content. */
|
||||||
|
export async function sendTextMessage(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const payload = buildOutboundPayload(text);
|
||||||
|
try {
|
||||||
|
return await sendMessagePayload(rt, chatId, payload, options);
|
||||||
|
} catch (e) {
|
||||||
|
if (payload.msgType === "post" && isPostContentFormatError(e)) {
|
||||||
|
try {
|
||||||
|
return await sendMessagePayload(
|
||||||
|
rt,
|
||||||
|
chatId,
|
||||||
|
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
|
||||||
|
export async function sendLongText(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
let lastMessageId: string | null = null;
|
||||||
|
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
|
||||||
|
const messageId = await sendTextMessage(rt, chatId, chunk, options);
|
||||||
|
if (messageId !== null) {
|
||||||
|
lastMessageId = messageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a plain text message (fire-and-forget). */
|
||||||
|
export async function sendText(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
await sendTextMessage(rt, chatId, text, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an interactive card message (always patchable). Used for streaming. */
|
||||||
|
export async function sendInteractiveCardMessage(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const payload = buildOutboundPayload(text);
|
||||||
|
const card = buildInteractiveCardFromOutboundPayload(payload);
|
||||||
|
try {
|
||||||
|
return await sendMessagePayload(
|
||||||
|
rt,
|
||||||
|
chatId,
|
||||||
|
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a text-as-card message in-place (streaming updates). */
|
||||||
|
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
||||||
|
const payload = buildOutboundPayload(text);
|
||||||
|
const card = buildInteractiveCardFromOutboundPayload(payload);
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await client.im.v1.message.patch({
|
||||||
|
path: { message_id: messageId },
|
||||||
|
params: { msg_type: "interactive" },
|
||||||
|
data: { content: JSON.stringify(card) },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
|
||||||
|
export async function sendCard(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
card: Record<string, unknown>,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await sendMessagePayload(
|
||||||
|
rt,
|
||||||
|
chatId,
|
||||||
|
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch an interactive card in-place with raw JSON. */
|
||||||
|
export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record<string, unknown>): Promise<boolean> {
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await client.im.v1.message.patch({
|
||||||
|
path: { message_id: messageId },
|
||||||
|
params: { msg_type: "interactive" },
|
||||||
|
data: { content: JSON.stringify(card) },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an approval card with buttons. Returns message_id on success. */
|
||||||
|
export async function sendApprovalCard(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const card = {
|
||||||
|
config: { wide_screen_mode: true },
|
||||||
|
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
|
||||||
|
elements: [
|
||||||
|
{ tag: "markdown", content: body },
|
||||||
|
{
|
||||||
|
tag: "action",
|
||||||
|
actions: buttons.map((button) => ({
|
||||||
|
tag: "button",
|
||||||
|
text: { tag: "plain_text", content: button.label },
|
||||||
|
type: button.style ?? "default",
|
||||||
|
value: { approval_action: button.value },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await sendMessagePayload(
|
||||||
|
rt,
|
||||||
|
chatId,
|
||||||
|
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a card to show it has been resolved. */
|
||||||
|
export async function resolveCard(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
messageId: string,
|
||||||
|
icon: string,
|
||||||
|
label: string,
|
||||||
|
template: "green" | "red",
|
||||||
|
resolvedBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const card = {
|
||||||
|
config: { wide_screen_mode: true },
|
||||||
|
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
|
||||||
|
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
|
||||||
|
};
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await client.im.v1.message.patch({
|
||||||
|
path: { message_id: messageId },
|
||||||
|
params: { msg_type: "interactive" },
|
||||||
|
data: { content: JSON.stringify(card) },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React to a message with an emoji. */
|
||||||
|
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rt.client.request({
|
||||||
|
method: "POST",
|
||||||
|
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
||||||
|
data: { reaction_type: { emoji_type: emoji } },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
|
||||||
|
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await rt.client.request({
|
||||||
|
method: "POST",
|
||||||
|
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
||||||
|
data: { reaction_type: { emoji_type: emojiType } },
|
||||||
|
}) as ReactionCreateResponse;
|
||||||
|
const reactionId = reactionIdFromResponse(res);
|
||||||
|
if (reactionId === null) {
|
||||||
|
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
|
||||||
|
}
|
||||||
|
return reactionId;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a previously added reaction by its reaction_id. */
|
||||||
|
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await rt.client.request({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
|
||||||
|
export async function resolveSenderName(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
openId: string,
|
||||||
|
cache?: SenderNameCache,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (openId === "") return null;
|
||||||
|
|
||||||
|
const fetchName = async (id: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const res = await rt.client.contact.v3.user.basicBatch({
|
||||||
|
params: { user_id_type: "open_id" },
|
||||||
|
data: { user_ids: [id] },
|
||||||
|
}) as ContactUserBasicBatchResponse;
|
||||||
|
const user = res?.data?.users?.[0];
|
||||||
|
return user === undefined ? null : contactBasicUserName(user);
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cache !== undefined) {
|
||||||
|
return cache.getOrFetch(openId, fetchName);
|
||||||
|
}
|
||||||
|
return fetchName(openId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactBasicUserName(user: ContactBasicUser): string | null {
|
||||||
|
const candidates = [
|
||||||
|
user.name,
|
||||||
|
user.i18n_name?.zh_cn,
|
||||||
|
user.i18n_name?.en_us,
|
||||||
|
user.i18n_name?.ja_jp,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File helpers ---
|
||||||
|
|
||||||
|
/** Download a file/image from a Feishu message to a local path. */
|
||||||
|
export async function downloadMessageFile(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
messageId: string,
|
||||||
|
fileKey: string,
|
||||||
|
savePath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await mkdir(dirname(savePath), { recursive: true });
|
||||||
|
try {
|
||||||
|
const res = await withRetry(async () => {
|
||||||
|
return rt.client.request({
|
||||||
|
method: "GET",
|
||||||
|
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
|
||||||
|
params: { type: "file" },
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
}) as Promise<{ data: Buffer }>;
|
||||||
|
});
|
||||||
|
await writeFile(savePath, res.data);
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload and send a file to a chat. */
|
||||||
|
export async function sendFile(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
filePath: string,
|
||||||
|
fileName: string,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: {
|
||||||
|
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
||||||
|
} };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const buf = await readFile(filePath);
|
||||||
|
const ext = fileName.split(".").pop() ?? "";
|
||||||
|
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
||||||
|
const uploadRes = await withRetry(async () =>
|
||||||
|
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
|
||||||
|
);
|
||||||
|
const fileKey = fileKeyFromResponse(uploadRes);
|
||||||
|
if (fileKey === undefined) {
|
||||||
|
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const messageId = await withRetry(async () =>
|
||||||
|
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
|
||||||
|
);
|
||||||
|
if (messageId === null) {
|
||||||
|
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
|
||||||
|
}
|
||||||
|
return messageId;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessagePayload(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
payload: OutboundPayload,
|
||||||
|
options?: SendMessageOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: {
|
||||||
|
create: (p: unknown) => Promise<MessageCreateResponse>;
|
||||||
|
reply: (p: unknown) => Promise<MessageCreateResponse>;
|
||||||
|
} } };
|
||||||
|
};
|
||||||
|
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
|
||||||
|
if (replyToMessageId !== null) {
|
||||||
|
const res = await client.im.v1.message.reply({
|
||||||
|
path: { message_id: replyToMessageId },
|
||||||
|
data: {
|
||||||
|
msg_type: payload.msgType,
|
||||||
|
content: payload.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return messageIdFromResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: "chat_id" },
|
||||||
|
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
|
||||||
|
});
|
||||||
|
return messageIdFromResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplyMessageId(messageId: string | undefined): string | null {
|
||||||
|
return messageId === undefined || messageId === "" ? null : messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageCreateResponse = {
|
||||||
|
readonly message_id?: string | undefined;
|
||||||
|
readonly data?: { readonly message_id?: string | undefined } | undefined;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type FileCreateResponse = {
|
||||||
|
readonly file_key?: string | undefined;
|
||||||
|
readonly data?: { readonly file_key?: string | undefined } | undefined;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type ReactionCreateResponse = {
|
||||||
|
readonly reaction_id?: string | undefined;
|
||||||
|
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function messageIdFromResponse(res: MessageCreateResponse): string | null {
|
||||||
|
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
|
||||||
|
return res?.file_key ?? res?.data?.file_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionIdFromResponse(res: ReactionCreateResponse): string | null {
|
||||||
|
return res?.data?.reaction_id ?? res?.reaction_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(content: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
return isRecord(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function postRoot(parsed: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const post = parsed["post"];
|
||||||
|
return isRecord(post) ? post : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPostLocale(root: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
if (Array.isArray(root["content"])) return root;
|
||||||
|
|
||||||
|
for (const locale of ["zh_cn", "en_us"]) {
|
||||||
|
const candidate = root[locale];
|
||||||
|
if (isPostLocale(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of Object.values(root)) {
|
||||||
|
if (isPostLocale(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPostLocale(value: unknown): value is Record<string, unknown> {
|
||||||
|
return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string {
|
||||||
|
if (!isRecord(element)) return "";
|
||||||
|
|
||||||
|
switch (stringField(element, "tag")) {
|
||||||
|
case "text":
|
||||||
|
return applyTextStyles(stringField(element, "text") ?? "", element);
|
||||||
|
case "a":
|
||||||
|
return renderLink(element);
|
||||||
|
case "at":
|
||||||
|
return renderMention(element);
|
||||||
|
case "img":
|
||||||
|
case "image":
|
||||||
|
pushStringField(imageKeys, element, "image_key");
|
||||||
|
return "[Image]";
|
||||||
|
case "media":
|
||||||
|
case "file":
|
||||||
|
case "audio":
|
||||||
|
case "video":
|
||||||
|
pushStringField(fileKeys, element, "file_key");
|
||||||
|
return renderAttachmentPlaceholder(element);
|
||||||
|
case "code":
|
||||||
|
case "code_block":
|
||||||
|
return renderCodeBlock(element);
|
||||||
|
case "br":
|
||||||
|
return "\n";
|
||||||
|
case "hr":
|
||||||
|
case "divider":
|
||||||
|
return "\n\n---\n\n";
|
||||||
|
case "emotion":
|
||||||
|
case "emoji":
|
||||||
|
return renderEmoji(element);
|
||||||
|
case "md":
|
||||||
|
return stringField(element, "text") ?? "";
|
||||||
|
default:
|
||||||
|
return stringField(element, "text") ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLink(element: Record<string, unknown>): string {
|
||||||
|
const text = stringField(element, "text") ?? "";
|
||||||
|
const href = stringField(element, "href") ?? "";
|
||||||
|
if (href === "") return text;
|
||||||
|
return `[${text}](${href})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMention(element: Record<string, unknown>): string {
|
||||||
|
if (stringField(element, "user_id") === "@_all") return "@all";
|
||||||
|
const name =
|
||||||
|
stringField(element, "user_name") ??
|
||||||
|
stringField(element, "name") ??
|
||||||
|
stringField(element, "text") ??
|
||||||
|
stringField(element, "user_id") ??
|
||||||
|
"";
|
||||||
|
return name === "" ? "" : `@${name.replace(/^@/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachmentPlaceholder(element: Record<string, unknown>): string {
|
||||||
|
const fileName = stringField(element, "file_name") ?? stringField(element, "name");
|
||||||
|
return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCodeBlock(element: Record<string, unknown>): string {
|
||||||
|
const language = stringField(element, "language") ?? "";
|
||||||
|
const text = stringField(element, "text") ?? "";
|
||||||
|
return `\`\`\`${language}\n${text}\n\`\`\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmoji(element: Record<string, unknown>): string {
|
||||||
|
const emojiType =
|
||||||
|
stringField(element, "emoji_type") ??
|
||||||
|
stringField(element, "emotion_key") ??
|
||||||
|
stringField(element, "name") ??
|
||||||
|
stringField(element, "text");
|
||||||
|
return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextStyles(text: string, element: Record<string, unknown>): string {
|
||||||
|
let rendered = text;
|
||||||
|
if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`;
|
||||||
|
if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`;
|
||||||
|
if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`;
|
||||||
|
if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``;
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPostStyle(element: Record<string, unknown>, ...names: string[]): boolean {
|
||||||
|
const style = element["style"] ?? element["styles"];
|
||||||
|
if (styleMatches(style, names)) return true;
|
||||||
|
return names.some((name) => element[name] === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleMatches(style: unknown, names: readonly string[]): boolean {
|
||||||
|
if (Array.isArray(style)) {
|
||||||
|
return style.some((entry) => typeof entry === "string" && names.includes(entry));
|
||||||
|
}
|
||||||
|
if (typeof style === "string") {
|
||||||
|
const tokens = style.split(/[\s,]+/).filter((token) => token !== "");
|
||||||
|
return tokens.some((token) => names.includes(token));
|
||||||
|
}
|
||||||
|
if (isRecord(style)) {
|
||||||
|
return names.some((name) => style[name] === true || style[name] === "true");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushStringField(target: string[], record: Record<string, unknown>, field: string): void {
|
||||||
|
const value = stringField(record, field);
|
||||||
|
if (value !== undefined && value !== "") target.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, field: string): string | undefined {
|
||||||
|
const value = record[field];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsMarkdownSyntax(content: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/^ {0,3}#{1,6}\s+\S/m,
|
||||||
|
/^ {0,3}>\s+\S/m,
|
||||||
|
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
|
||||||
|
/```/,
|
||||||
|
/`[^`\n]+`/,
|
||||||
|
/\*\*[^*\n]+\*\*/,
|
||||||
|
/__[^_\n]+__/,
|
||||||
|
/~~[^~\n]+~~/,
|
||||||
|
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
|
||||||
|
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
|
||||||
|
/\[[^\]\n]+\]\([^)]+\)/,
|
||||||
|
];
|
||||||
|
return patterns.some((pattern) => pattern.test(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsMarkdownTable(content: string): boolean {
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
for (let i = 0; i < lines.length - 1; i += 1) {
|
||||||
|
const current = lines[i];
|
||||||
|
const next = lines[i + 1];
|
||||||
|
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableHeaderLine(line: string): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return trimmed.startsWith("|") && trimmed.includes("|", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableSeparatorLine(line: string): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("|")) return false;
|
||||||
|
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
|
||||||
|
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
|
||||||
|
return {
|
||||||
|
elements: rowsFromOutboundPayload(payload).map((row) => ({
|
||||||
|
tag: "div",
|
||||||
|
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
|
||||||
|
if (payload.msgType !== "post") {
|
||||||
|
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
|
||||||
|
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
|
||||||
|
} catch {
|
||||||
|
return [[{ tag: "md", text: "" }]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromTextPayload(content: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as { text?: unknown };
|
||||||
|
return typeof parsed.text === "string" ? parsed.text : content;
|
||||||
|
} catch {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownTextFromPostElement(element: unknown): string {
|
||||||
|
if (typeof element !== "object" || element === null) return "";
|
||||||
|
const text = (element as { text?: unknown }).text;
|
||||||
|
return typeof text === "string" ? text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPostContentFormatError(error: unknown): boolean {
|
||||||
|
return errorText(error).includes("content format of the post type is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
|
||||||
|
}
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (typeof error === "object" && error !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lark client + ws listener ---
|
||||||
|
|
||||||
|
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||||
|
return new lark.Client({
|
||||||
|
appId: config.appId,
|
||||||
|
appSecret: config.appSecret,
|
||||||
|
appType: lark.AppType.SelfBuild,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startFeishuListenerWithClient(
|
||||||
|
config: FeishuConfig,
|
||||||
|
client: lark.Client,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
): FeishuRuntime {
|
||||||
|
const wsClient = new lark.WSClient({
|
||||||
|
appId: config.appId,
|
||||||
|
appSecret: config.appSecret,
|
||||||
|
domain: lark.Domain.Feishu,
|
||||||
|
loggerLevel: lark.LoggerLevel.info,
|
||||||
|
});
|
||||||
|
const rt: FeishuRuntime = { client, logger };
|
||||||
|
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
||||||
|
"im.message.receive_v1": async (data) => {
|
||||||
|
try { await onMessage(data as MessageReceiveEvent, rt); }
|
||||||
|
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (onCardAction !== undefined) {
|
||||||
|
handlers["card.action.trigger"] = async (data) => {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => onCardAction(data as CardActionEvent, rt))
|
||||||
|
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||||
|
return rt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startFeishuListener(
|
||||||
|
config: FeishuConfig,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
): FeishuRuntime {
|
||||||
|
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Feishu context reader — the real implementation behind `feishuContextTool`.
|
||||||
|
*
|
||||||
|
* ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool
|
||||||
|
* (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must
|
||||||
|
* match the project binding). Here we actually call lark's `im.v1.message`:
|
||||||
|
*
|
||||||
|
* - `trigger_message` / `reply`: `message.get` by message_id.
|
||||||
|
* - `status_card`: the run's status card message — same `message.get` by id.
|
||||||
|
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
||||||
|
* `parent_message_id` filter; we map "thread" to that.
|
||||||
|
*
|
||||||
|
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
|
||||||
|
* we cast through a known request/response shape and return a compact JSON for
|
||||||
|
* the model. All calls use the chat-authorized `client` from the runtime — no
|
||||||
|
* separate credential path, so the ADR-0003 invariant holds end-to-end.
|
||||||
|
*/
|
||||||
|
import type { FeishuRuntime } from "./client.js";
|
||||||
|
import type { FeishuContextArgs } from "../agent/tools.js";
|
||||||
|
import type { ToolContext } from "../agent/tools.js";
|
||||||
|
|
||||||
|
/** Pragmatic shape of the lark message object we consume. */
|
||||||
|
interface LarkMessage {
|
||||||
|
message_id?: string;
|
||||||
|
msg_type?: string;
|
||||||
|
content?: string;
|
||||||
|
body?: { content?: string };
|
||||||
|
create_time?: string;
|
||||||
|
sender?: { id?: string; sender_type?: string };
|
||||||
|
chat_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_message_id?: string;
|
||||||
|
root_id?: string;
|
||||||
|
thread_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImV1Message {
|
||||||
|
get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>;
|
||||||
|
list: (p: {
|
||||||
|
params: { container_id_type: string; container_id: string; page_size?: number };
|
||||||
|
}) => Promise<{ data?: { items?: LarkMessage[] } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function im(rt: FeishuRuntime): ImV1Message {
|
||||||
|
return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
|
||||||
|
export async function readFeishuContext(
|
||||||
|
args: FeishuContextArgs,
|
||||||
|
_ctx: ToolContext,
|
||||||
|
rt: unknown,
|
||||||
|
): Promise<string> {
|
||||||
|
const runtime = rt as FeishuRuntime;
|
||||||
|
const api = im(runtime);
|
||||||
|
try {
|
||||||
|
switch (args.anchor) {
|
||||||
|
case "trigger_message":
|
||||||
|
case "status_card":
|
||||||
|
case "reply": {
|
||||||
|
// All three are single-message reads by id.
|
||||||
|
const res = await api.get({ path: { message_id: args.id } });
|
||||||
|
const msg = res.data?.message ?? res.data?.items?.[0];
|
||||||
|
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
|
||||||
|
return JSON.stringify(compact(msg));
|
||||||
|
}
|
||||||
|
case "thread": {
|
||||||
|
// Thread = replies to a parent message. `container_id` is the parent's
|
||||||
|
// message_id; container_id_type=message_id scopes the list to that thread.
|
||||||
|
const res = await api.list({
|
||||||
|
params: {
|
||||||
|
container_id_type: "message_id",
|
||||||
|
container_id: args.id,
|
||||||
|
page_size: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const items = res.data?.items ?? [];
|
||||||
|
return JSON.stringify(items.map(compact));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a lark message down to the fields the model actually needs. */
|
||||||
|
function compact(m: LarkMessage): Record<string, unknown> {
|
||||||
|
const parentId = m.parent_id ?? m.parent_message_id;
|
||||||
|
return {
|
||||||
|
message_id: m.message_id,
|
||||||
|
msg_type: m.msg_type,
|
||||||
|
content: m.body?.content ?? m.content,
|
||||||
|
create_time: m.create_time,
|
||||||
|
chat_id: m.chat_id,
|
||||||
|
sender_id: m.sender?.id,
|
||||||
|
parent_id: parentId,
|
||||||
|
parent_message_id: parentId,
|
||||||
|
root_id: m.root_id,
|
||||||
|
thread_id: m.thread_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
|
||||||
|
*
|
||||||
|
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
|
||||||
|
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
|
||||||
|
* invariant ("holder is a non-terminal run") is app-level: it must hold on
|
||||||
|
* every read of the lock table. `assertLockHolderActive` enforces it on the
|
||||||
|
* read path — if a lock exists but its run is terminal, that's a bug (the run
|
||||||
|
* should have released it), surfaced as an error rather than silently trusted.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { AgentRunStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
|
||||||
|
/// active/waitingForUser are NOT terminal — the lock must still be held.
|
||||||
|
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
|
||||||
|
"COMPLETED",
|
||||||
|
"FAILED",
|
||||||
|
"TIMED_OUT",
|
||||||
|
"CANCELED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isTerminal(status: AgentRunStatus): boolean {
|
||||||
|
return TERMINAL_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the project lock for a run. Throws if the project is already locked
|
||||||
|
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
|
||||||
|
* run that already holds a lock elsewhere will also fail — that's correct, a
|
||||||
|
* run scopes to one project.
|
||||||
|
*/
|
||||||
|
export async function acquireLock(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
runId: string,
|
||||||
|
holderUserId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.projectAgentLock.create({
|
||||||
|
data: { projectId, runId, holderUserId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
|
||||||
|
* Called on run completion/failure/timeout/cancellation (ADR-0002).
|
||||||
|
*/
|
||||||
|
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
|
||||||
|
await prisma.projectAgentLock.deleteMany({ where: { runId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
|
||||||
|
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
|
||||||
|
* run still holds a lock — that's a release-path bug, not a recoverable state.
|
||||||
|
*
|
||||||
|
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
|
||||||
|
*/
|
||||||
|
export async function currentLockRunId(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const lock = await prisma.projectAgentLock.findUnique({
|
||||||
|
where: { projectId },
|
||||||
|
select: { runId: true },
|
||||||
|
});
|
||||||
|
if (lock === null) return null;
|
||||||
|
|
||||||
|
const run = await prisma.agentRun.findUnique({
|
||||||
|
where: { id: lock.runId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (run !== null && isTerminal(run.status)) {
|
||||||
|
throw new Error(
|
||||||
|
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lock.runId;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Permission compatibility exports.
|
||||||
|
*
|
||||||
|
* ADR-0019 moves production authorization behind PrincipalResolver +
|
||||||
|
* PermissionAuthorizer. The wrappers below preserve the old helper names for
|
||||||
|
* tests and incremental callers while routing project trigger checks through
|
||||||
|
* the new authorizer.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||||
|
|
||||||
|
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||||
|
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||||
|
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||||
|
export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js";
|
||||||
|
export type {
|
||||||
|
AuthorizationAction,
|
||||||
|
AuthorizationDecision,
|
||||||
|
AuthorizationRequest,
|
||||||
|
AuthorizationResource,
|
||||||
|
PermissionAuthorizer,
|
||||||
|
} from "./permissions/authorizer.js";
|
||||||
|
export type {
|
||||||
|
ActorInput,
|
||||||
|
PrincipalRef,
|
||||||
|
PrincipalResolution,
|
||||||
|
PrincipalResolver,
|
||||||
|
ResolvedPrincipal,
|
||||||
|
} from "./permissions/principals.js";
|
||||||
|
export type {
|
||||||
|
ExternalPrincipalMembershipInput,
|
||||||
|
ExternalPrincipalType,
|
||||||
|
SyncExternalPrincipalMembershipsInput,
|
||||||
|
SyncExternalPrincipalMembershipsResult,
|
||||||
|
} from "./permissions/externalSync.js";
|
||||||
|
export type {
|
||||||
|
GrantTeamProjectAccessInput,
|
||||||
|
ProjectTeamAccessEntry,
|
||||||
|
RevokeTeamProjectAccessInput,
|
||||||
|
} from "./permissions/projectTeamAccess.js";
|
||||||
|
|
||||||
|
export interface PermissionResult {
|
||||||
|
readonly allowed: boolean;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canTriggerAgent(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
principal: string,
|
||||||
|
): Promise<PermissionResult> {
|
||||||
|
try {
|
||||||
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: principal },
|
||||||
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: projectId },
|
||||||
|
});
|
||||||
|
return { allowed: decision.allowed, reason: decision.reason };
|
||||||
|
} catch (e) {
|
||||||
|
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-only legacy helper. New trigger code uses `role.trigger` on
|
||||||
|
* PermissionAuthorizer so project edit+ and role grant composition happen in
|
||||||
|
* one decision.
|
||||||
|
*/
|
||||||
|
export async function canTriggerRole(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
roleId: string,
|
||||||
|
principal: string,
|
||||||
|
): Promise<PermissionResult> {
|
||||||
|
try {
|
||||||
|
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||||
|
where: { projectId, roleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (anyGrant === null) {
|
||||||
|
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||||
|
}
|
||||||
|
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||||
|
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (grant !== null) {
|
||||||
|
return { allowed: true, reason: `granted role ${roleId}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Hub entry — Fastify server + Feishu WebSocket listener + org admin HTTP.
|
||||||
|
*
|
||||||
|
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
||||||
|
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
||||||
|
* the project lock (ADR-0002), runs the AI SDK-backed agent runner
|
||||||
|
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
||||||
|
* loop are ADR-0003-authorized (chat must match binding).
|
||||||
|
*
|
||||||
|
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
|
||||||
|
*
|
||||||
|
* Env (see .env.example):
|
||||||
|
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
|
||||||
|
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||||
|
import { prisma } from "./db.js";
|
||||||
|
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||||
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
|
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (v === undefined || v === "") {
|
||||||
|
console.error(`[hub] missing required env: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanEnv(name: string, fallback: boolean): boolean {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === "") return fallback;
|
||||||
|
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const databaseUrl = requireEnv("DATABASE_URL");
|
||||||
|
void databaseUrl;
|
||||||
|
|
||||||
|
const runtimeSettings = createEnvRuntimeSettings();
|
||||||
|
await runtimeSettings.provider("openrouter");
|
||||||
|
|
||||||
|
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||||
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
|
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||||
|
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||||
|
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||||
|
const port = Number(process.env["PORT"] ?? "8788");
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||||
|
await prisma.projectAgentLock.deleteMany({});
|
||||||
|
await prisma.agentRun.updateMany({
|
||||||
|
where: { status: "ACTIVE" },
|
||||||
|
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||||
|
});
|
||||||
|
app.log.info("startup: cleared stale locks + dead runs");
|
||||||
|
|
||||||
|
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
|
await registerAdminPlugin(app, {
|
||||||
|
prisma,
|
||||||
|
sessionSecret,
|
||||||
|
publicBaseUrl,
|
||||||
|
feishuAppId,
|
||||||
|
feishuAppSecret,
|
||||||
|
projectWorkspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Feishu listener ---
|
||||||
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||||
|
if (feishuListenerEnabled) {
|
||||||
|
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||||
|
const larkClient = createLarkClient(feishuConfig);
|
||||||
|
const triggerQueuePurgeTimer = setInterval(() => {
|
||||||
|
const removed = triggerQueue.purgeExpired();
|
||||||
|
if (removed > 0) {
|
||||||
|
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
triggerQueuePurgeTimer.unref();
|
||||||
|
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
||||||
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||||
|
} else {
|
||||||
|
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||||
|
if (err !== null) {
|
||||||
|
app.log.error({ err }, "listen failed");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
app.log.info({ address }, "hub listening");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("[hub] fatal:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { cp, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
|
||||||
|
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
|
||||||
|
|
||||||
|
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
|
||||||
|
|
||||||
|
describe("cph subprocess tools (integration, real cph binary)", () => {
|
||||||
|
let ws: string;
|
||||||
|
let tools: ToolRegistry;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Use a writable copy of the real TH-141 example as the workspace.
|
||||||
|
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
|
||||||
|
await cp(EXAMPLES_DIR, ws, { recursive: true });
|
||||||
|
tools = new ToolRegistry();
|
||||||
|
tools.register("cph_check", cphCheckTool);
|
||||||
|
tools.register("cph_build", cphBuildTool);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(ws, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
|
||||||
|
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
||||||
|
const out = await executeTool(tools, "cph_check", {}, ctx);
|
||||||
|
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
|
||||||
|
|
||||||
|
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
|
||||||
|
// If the example has warnings, exit is still 0.
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("cph_build renders the student target PDF", async () => {
|
||||||
|
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
||||||
|
const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
|
||||||
|
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("cph_check on a temp dir with no engineering file returns non-zero", async () => {
|
||||||
|
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
|
||||||
|
try {
|
||||||
|
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
|
||||||
|
const out = await executeTool(tools, "cph_check", {}, ctx);
|
||||||
|
const result = JSON.parse(out) as { exitCode: number };
|
||||||
|
expect(result.exitCode).not.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await rm(empty, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||||
|
const def = tools.build(ctx)[name];
|
||||||
|
if (def?.execute === undefined) {
|
||||||
|
throw new Error(`missing executable tool: ${name}`);
|
||||||
|
}
|
||||||
|
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* Test helpers for integration tests.
|
||||||
|
*
|
||||||
|
* Each test gets a clean DB (tables truncated before the test), a mock
|
||||||
|
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
|
||||||
|
* AI SDK model factory (doGenerate() returns canned responses - no network).
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type {
|
||||||
|
LanguageModelV4,
|
||||||
|
LanguageModelV4CallOptions,
|
||||||
|
LanguageModelV4Content,
|
||||||
|
LanguageModelV4GenerateResult,
|
||||||
|
LanguageModelV4StreamPart,
|
||||||
|
LanguageModelV4StreamResult,
|
||||||
|
} from "@ai-sdk/provider";
|
||||||
|
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||||
|
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||||
|
|
||||||
|
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||||
|
export const DEFAULT_ORG_ID = "org_test_default";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Truncate all tables before each test for isolation. */
|
||||||
|
export async function resetDb(): Promise<void> {
|
||||||
|
const tables = [
|
||||||
|
"FeishuEventReceipt",
|
||||||
|
"AgentFileChange",
|
||||||
|
"AgentMessage",
|
||||||
|
"AuditEntry",
|
||||||
|
"PermissionSettings",
|
||||||
|
"PermissionGrant",
|
||||||
|
"RoleTriggerGrant",
|
||||||
|
"ExternalPrincipalMembership",
|
||||||
|
"ExternalDirectoryConnection",
|
||||||
|
"TeamExternalBinding",
|
||||||
|
"TeamMembership",
|
||||||
|
"Team",
|
||||||
|
"ProjectAgentLock",
|
||||||
|
"AgentRun",
|
||||||
|
"AgentSession",
|
||||||
|
"ProjectGroupBinding",
|
||||||
|
"Folder",
|
||||||
|
"OrganizationProjectSettings",
|
||||||
|
"OrganizationMembership",
|
||||||
|
"PlatformRoleAssignment",
|
||||||
|
"User",
|
||||||
|
"Project",
|
||||||
|
"Organization",
|
||||||
|
];
|
||||||
|
// Truncate with CASCADE to wipe dependent rows in one shot.
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
||||||
|
await seedTestOrganization();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedTestOrganization(
|
||||||
|
id: string = DEFAULT_ORG_ID,
|
||||||
|
slug: string = "test-default",
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.organization.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name: "Test Default Organization",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.organizationProjectSettings.upsert({
|
||||||
|
where: { organizationId: id },
|
||||||
|
update: {},
|
||||||
|
create: { organizationId: id, membersCanCreateProjects: true },
|
||||||
|
});
|
||||||
|
const inbox = await prisma.folder.findFirst({
|
||||||
|
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (inbox === null) {
|
||||||
|
await prisma.folder.create({
|
||||||
|
data: {
|
||||||
|
id: `folder_inbox_${id}`,
|
||||||
|
organizationId: id,
|
||||||
|
name: "Inbox",
|
||||||
|
sortKey: "000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||||
|
export const silentLogger: FastifyBaseLogger = {
|
||||||
|
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
|
||||||
|
child() { return this; },
|
||||||
|
level: "silent",
|
||||||
|
} as unknown as FastifyBaseLogger;
|
||||||
|
|
||||||
|
/** Records sendText/sendCard calls so tests can assert on them. */
|
||||||
|
export interface MockFeishuRuntime extends FeishuRuntime {
|
||||||
|
readonly sentTexts: string[];
|
||||||
|
readonly sentCards: unknown[];
|
||||||
|
readonly sentPatches: unknown[];
|
||||||
|
readonly sentReplies: Array<{
|
||||||
|
readonly messageId: string;
|
||||||
|
readonly msgType: string;
|
||||||
|
readonly content: string;
|
||||||
|
readonly replyInThread: unknown;
|
||||||
|
}>;
|
||||||
|
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
|
||||||
|
readonly readableMessages: Map<string, MockFeishuMessage>;
|
||||||
|
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockFeishuMessage {
|
||||||
|
readonly message_id?: string;
|
||||||
|
readonly root_id?: string;
|
||||||
|
readonly parent_id?: string;
|
||||||
|
readonly thread_id?: string;
|
||||||
|
readonly msg_type?: string;
|
||||||
|
readonly create_time?: string;
|
||||||
|
readonly chat_id?: string;
|
||||||
|
readonly sender?: { readonly id?: string; readonly sender_type?: string };
|
||||||
|
readonly body?: { readonly content?: string };
|
||||||
|
readonly content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockFeishuRuntime(): MockFeishuRuntime {
|
||||||
|
const sentTexts: string[] = [];
|
||||||
|
const sentCards: unknown[] = [];
|
||||||
|
const sentPatches: unknown[] = [];
|
||||||
|
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
|
||||||
|
const reactions: Array<{ messageId: string; emoji: string }> = [];
|
||||||
|
const readableMessages = new Map<string, MockFeishuMessage>();
|
||||||
|
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
|
||||||
|
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
||||||
|
// object directly. We patch by providing a runtime whose client is a stub;
|
||||||
|
// the actual send functions cast through shape, so a minimal stub works.
|
||||||
|
const rt: MockFeishuRuntime = {
|
||||||
|
client: {
|
||||||
|
request: async (p: unknown) => {
|
||||||
|
const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } };
|
||||||
|
const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/);
|
||||||
|
if (match?.[1] !== undefined) {
|
||||||
|
reactions.push({
|
||||||
|
messageId: match[1],
|
||||||
|
emoji: payload.data?.reaction_type?.emoji_type ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
im: {
|
||||||
|
v1: {
|
||||||
|
message: {
|
||||||
|
create: async (p: unknown) => {
|
||||||
|
const payload = p as { data?: { msg_type?: string; content?: string } };
|
||||||
|
recordSentMessage(payload.data, sentTexts, sentCards);
|
||||||
|
return { data: { message_id: "mock-msg-id" } };
|
||||||
|
},
|
||||||
|
reply: async (p: unknown) => {
|
||||||
|
const payload = p as {
|
||||||
|
path?: { message_id?: string };
|
||||||
|
data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
|
||||||
|
};
|
||||||
|
sentReplies.push({
|
||||||
|
messageId: payload.path?.message_id ?? "",
|
||||||
|
msgType: payload.data?.msg_type ?? "",
|
||||||
|
content: payload.data?.content ?? "",
|
||||||
|
replyInThread: payload.data?.reply_in_thread,
|
||||||
|
});
|
||||||
|
recordSentMessage(payload.data, sentTexts, sentCards);
|
||||||
|
return { data: { message_id: "mock-msg-id" } };
|
||||||
|
},
|
||||||
|
patch: async (p: unknown) => {
|
||||||
|
const payload = p as { data?: { content?: string } };
|
||||||
|
const card = payload.data?.content ? JSON.parse(payload.data.content) : null;
|
||||||
|
sentPatches.push(card);
|
||||||
|
const text = textFromCard(card);
|
||||||
|
if (text !== null) sentTexts.push(text);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
get: async (p: unknown) => {
|
||||||
|
const payload = p as { path?: { message_id?: string } };
|
||||||
|
const messageId = payload.path?.message_id ?? "";
|
||||||
|
const message = readableMessages.get(messageId);
|
||||||
|
return { data: { items: message === undefined ? [] : [message] } };
|
||||||
|
},
|
||||||
|
list: async (p: unknown) => {
|
||||||
|
const payload = p as { params?: { container_id?: string } };
|
||||||
|
const containerId = payload.params?.container_id ?? "";
|
||||||
|
const items =
|
||||||
|
threadMessages.get(containerId) ??
|
||||||
|
[...readableMessages.values()].filter((message) => message.parent_id === containerId);
|
||||||
|
return { data: { items } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as FeishuRuntime["client"],
|
||||||
|
logger: silentLogger,
|
||||||
|
sentTexts,
|
||||||
|
sentCards,
|
||||||
|
sentPatches,
|
||||||
|
sentReplies,
|
||||||
|
reactions,
|
||||||
|
readableMessages,
|
||||||
|
threadMessages,
|
||||||
|
};
|
||||||
|
return rt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSentMessage(
|
||||||
|
data: { msg_type?: string; content?: string } | undefined,
|
||||||
|
sentTexts: string[],
|
||||||
|
sentCards: unknown[],
|
||||||
|
): void {
|
||||||
|
if (data?.msg_type === "interactive") {
|
||||||
|
const card = data.content ? JSON.parse(data.content) : null;
|
||||||
|
sentCards.push(card);
|
||||||
|
const text = textFromCard(card);
|
||||||
|
if (text !== null) sentTexts.push(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const c = JSON.parse(data?.content ?? "{}") as { text?: string };
|
||||||
|
sentTexts.push(c.text ?? data?.content ?? "");
|
||||||
|
} catch {
|
||||||
|
sentTexts.push(data?.content ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromCard(card: unknown): string | null {
|
||||||
|
if (typeof card !== "object" || card === null) return null;
|
||||||
|
const elements = (card as { elements?: unknown }).elements;
|
||||||
|
if (!Array.isArray(elements)) return null;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const element of elements) {
|
||||||
|
if (typeof element !== "object" || element === null) continue;
|
||||||
|
// New format: { tag: "markdown", content: string }
|
||||||
|
const tag = (element as { tag?: unknown }).tag;
|
||||||
|
const directContent = (element as { content?: unknown }).content;
|
||||||
|
if (tag === "markdown" && typeof directContent === "string") {
|
||||||
|
parts.push(directContent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Old format: { text: { content: string } }
|
||||||
|
const text = (element as { text?: unknown }).text;
|
||||||
|
if (typeof text === "object" && text !== null) {
|
||||||
|
const content = (text as { content?: unknown }).content;
|
||||||
|
if (typeof content === "string") parts.push(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length === 0 ? null : parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockToolCall {
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockModelResponse {
|
||||||
|
readonly text?: string;
|
||||||
|
readonly toolCalls?: readonly MockToolCall[];
|
||||||
|
readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
|
||||||
|
readonly inputTokens?: number;
|
||||||
|
readonly outputTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockLanguageModel implements LanguageModelV4 {
|
||||||
|
readonly specificationVersion = "v4";
|
||||||
|
readonly provider = "mock";
|
||||||
|
readonly supportedUrls: Record<string, RegExp[]> = {};
|
||||||
|
readonly calls: LanguageModelV4CallOptions[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly modelId: string,
|
||||||
|
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
|
||||||
|
this.calls.push(options);
|
||||||
|
const configured = this.responses[this.calls.length - 1];
|
||||||
|
const last = this.responses[this.responses.length - 1];
|
||||||
|
const response =
|
||||||
|
configured ??
|
||||||
|
((last?.toolCalls?.length ?? 0) > 0
|
||||||
|
? { text: "mock response" }
|
||||||
|
: last ?? { text: "mock response" });
|
||||||
|
const content: LanguageModelV4Content[] = [];
|
||||||
|
if (response.text !== undefined) {
|
||||||
|
content.push({ type: "text", text: response.text });
|
||||||
|
}
|
||||||
|
for (const call of response.toolCalls ?? []) {
|
||||||
|
content.push({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: call.toolCallId,
|
||||||
|
toolName: call.toolName,
|
||||||
|
input: JSON.stringify(call.input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
finishReason: { unified: finishReason, raw: finishReason },
|
||||||
|
usage: {
|
||||||
|
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||||
|
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
|
||||||
|
},
|
||||||
|
response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
|
||||||
|
this.calls.push(options);
|
||||||
|
const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!;
|
||||||
|
const text = response.text ?? "";
|
||||||
|
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
|
||||||
|
const id = `mock-${this.calls.length}`;
|
||||||
|
|
||||||
|
const parts: LanguageModelV4StreamPart[] = [
|
||||||
|
{ type: "text-start", id },
|
||||||
|
];
|
||||||
|
if (text.length > 0) {
|
||||||
|
parts.push({ type: "text-delta", id, delta: text });
|
||||||
|
}
|
||||||
|
parts.push({ type: "text-end", id });
|
||||||
|
for (const call of response.toolCalls ?? []) {
|
||||||
|
const tcId = `tc-${this.calls.length}-${call.toolName}`;
|
||||||
|
parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName });
|
||||||
|
parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) });
|
||||||
|
parts.push({ type: "tool-input-end", id: tcId });
|
||||||
|
}
|
||||||
|
parts.push({
|
||||||
|
type: "finish",
|
||||||
|
finishReason: { unified: finishReason, raw: finishReason },
|
||||||
|
usage: {
|
||||||
|
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||||
|
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
for (const part of parts) controller.enqueue(part);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
response: { id, timestamp: new Date(), modelId: "mock-model" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockModelFactory(
|
||||||
|
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||||
|
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
|
||||||
|
const models = new Map<string, MockLanguageModel>();
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
modelFactory: (modelId) => {
|
||||||
|
const model = new MockLanguageModel(modelId, responses);
|
||||||
|
models.set(modelId, model);
|
||||||
|
return model;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a project + binding + user + grant for a test. */
|
||||||
|
export async function seedProject(
|
||||||
|
projectId: string,
|
||||||
|
chatId: string,
|
||||||
|
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const principal = options.principal ?? "ou_test_user";
|
||||||
|
const role = options.role ?? "EDIT";
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: projectId,
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
name: `Test ${projectId}`,
|
||||||
|
workspaceDir: `/tmp/test-${projectId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: "u_" + projectId,
|
||||||
|
feishuOpenId: principal,
|
||||||
|
displayName: "Test User",
|
||||||
|
platformRoles: { create: { role: "TEACHER" } },
|
||||||
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
|
||||||
|
permissionGrants: {
|
||||||
|
create: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: projectId,
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: principal,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.projectGroupBinding.create({
|
||||||
|
data: { projectId, chatId, createdByUserId: "u_" + projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||||
|
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||||
|
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
|
||||||
|
|
||||||
|
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acquireLock + currentLockRunId round-trip", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
const run = await prisma.agentRun.create({
|
||||||
|
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await acquireLock(prisma, project.id, run.id, null);
|
||||||
|
const holder = await currentLockRunId(prisma, project.id);
|
||||||
|
expect(holder).toBe(run.id);
|
||||||
|
|
||||||
|
await releaseLock(prisma, run.id);
|
||||||
|
const after = await currentLockRunId(prisma, project.id);
|
||||||
|
expect(after).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acquireLock fails when project already locked (exclusivity)", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
const run1 = await prisma.agentRun.create({
|
||||||
|
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||||
|
});
|
||||||
|
const run2 = await prisma.agentRun.create({
|
||||||
|
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await acquireLock(prisma, project.id, run1.id, null);
|
||||||
|
await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
|
||||||
|
const run = await prisma.agentRun.create({
|
||||||
|
data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() },
|
||||||
|
});
|
||||||
|
await prisma.projectAgentLock.create({
|
||||||
|
data: { projectId: project.id, runId: run.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// WellFormed invariant: reading a lock held by a terminal run must throw.
|
||||||
|
await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releaseLock is idempotent", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
const run = await prisma.agentRun.create({
|
||||||
|
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await acquireLock(prisma, project.id, run.id, null);
|
||||||
|
await releaseLock(prisma, run.id);
|
||||||
|
// Second release is a no-op.
|
||||||
|
await releaseLock(prisma, run.id);
|
||||||
|
expect(await currentLockRunId(prisma, project.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isTerminal matches spec RunState.Terminal", () => {
|
||||||
|
expect(isTerminal("ACTIVE")).toBe(false);
|
||||||
|
expect(isTerminal("WAITING_FOR_USER")).toBe(false);
|
||||||
|
expect(isTerminal("COMPLETED")).toBe(true);
|
||||||
|
expect(isTerminal("FAILED")).toBe(true);
|
||||||
|
expect(isTerminal("TIMED_OUT")).toBe(true);
|
||||||
|
expect(isTerminal("CANCELED")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||||
|
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||||
|
import { canTriggerRole } from "../../src/permission.js";
|
||||||
|
|
||||||
|
describe("canTriggerRole (integration, per-role gate)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||||
|
expect(r.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows when principal holds an active grant for the role", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
|
});
|
||||||
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||||
|
expect(r.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when grants exist for the role but principal has none", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
// Someone else has the role; ou_b does not.
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
|
});
|
||||||
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
||||||
|
expect(r.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when the principal's grant was revoked", async () => {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||||
|
expect(r.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
|
||||||
|
const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||||
|
const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
|
});
|
||||||
|
// p2 has the role configured for someone else; ou_a has no grant there.
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||||
|
});
|
||||||
|
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
||||||
|
expect(onP1.allowed).toBe(true);
|
||||||
|
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
|
||||||
|
expect(onP2.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { writeAudit } from "../../src/audit.js";
|
||||||
|
|
||||||
|
/// Minimal mock: only the surface `writeAudit` touches. Cast through unknown
|
||||||
|
/// so we don't have to implement the rest of the PrismaClient surface — this
|
||||||
|
/// is a unit test, not a DB test.
|
||||||
|
interface AuditEntryMock {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
function mockPrisma(): PrismaClient & { auditEntry: AuditEntryMock } {
|
||||||
|
return { auditEntry: { create: vi.fn() } } as unknown as PrismaClient & {
|
||||||
|
auditEntry: AuditEntryMock;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeAudit (A-8 audit write path)", () => {
|
||||||
|
it("creates a row with all fields set", async () => {
|
||||||
|
const prisma = mockPrisma();
|
||||||
|
prisma.auditEntry.create.mockResolvedValue({ id: "aud-1" });
|
||||||
|
|
||||||
|
await writeAudit(prisma, {
|
||||||
|
runId: "run-1",
|
||||||
|
projectId: "proj-1",
|
||||||
|
actorUserId: "user-1",
|
||||||
|
action: "run.triggered",
|
||||||
|
metadata: { source: "feishu" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
runId: "run-1",
|
||||||
|
projectId: "proj-1",
|
||||||
|
actorUserId: "user-1",
|
||||||
|
action: "run.triggered",
|
||||||
|
metadata: { source: "feishu" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a row with only required fields (action + metadata)", async () => {
|
||||||
|
const prisma = mockPrisma();
|
||||||
|
prisma.auditEntry.create.mockResolvedValue({ id: "aud-2" });
|
||||||
|
|
||||||
|
await writeAudit(prisma, {
|
||||||
|
action: "permission.denied",
|
||||||
|
metadata: { reason: "no-edit-role" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
action: "permission.denied",
|
||||||
|
metadata: { reason: "no-edit-role" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a prisma error (best-effort, must not throw)", async () => {
|
||||||
|
const prisma = mockPrisma();
|
||||||
|
prisma.auditEntry.create.mockRejectedValue(new Error("db down"));
|
||||||
|
|
||||||
|
// Must not throw — audit is observability, not control.
|
||||||
|
await expect(
|
||||||
|
writeAudit(prisma, {
|
||||||
|
action: "run.triggered",
|
||||||
|
metadata: {},
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user