feat: add deployable alpha silo

This commit is contained in:
2026-07-11 00:25:45 +08:00
parent 44557da499
commit 9e954790dc
57 changed files with 2792 additions and 400 deletions
+1 -31
View File
@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "24"
cache: npm cache: npm
cache-dependency-path: hub/package-lock.json cache-dependency-path: hub/package-lock.json
@@ -112,36 +112,6 @@ jobs:
env: env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test 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 \
HOST=127.0.0.1 \
HUB_PROJECT_WORKSPACE_ROOT=/tmp/cph-hub-smoke-workspaces \
HUB_PUBLIC_BASE_URL=https://hub.example.test \
HUB_SESSION_SECRET=smoke-session-secret-with-at-least-32-bytes \
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 # Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter. # OPENROUTER_API_KEY when a branch should hit live OpenRouter.
- name: Run real-model integration tests (optional, needs secret) - name: Run real-model integration tests (optional, needs secret)
+4
View File
@@ -24,6 +24,10 @@
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用 - 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走 客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。 双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
## 纪律 ## 纪律
+57
View File
@@ -0,0 +1,57 @@
# ADR 0025: Alpha Uses Managed Single-Organization Silos
## Status
Accepted for the supervised alpha.
## Context
The shared SaaS control plane, durable cross-Organization admission scheduler,
and customer administration SPA are not complete. Real customer data must not
depend on partially migrated application-level tenant filtering merely to start
the supervised alpha.
## Decision
Each alpha Organization runs as a separately named Silo deployment. A Silo has
one Hub systemd unit and service identity, one logical PostgreSQL database and
database role, one workspace root, one environment/session secret, one local
master-key keyring, one Feishu Application Connection, and Organization-local
Provider Connections. The process is pinned by `HUB_SILO_ORGANIZATION_ID` and
refuses startup unless its database contains exactly that one active
Organization.
Application releases remain shared and versioned; customer state does not live
inside a release directory. Host-console bootstrap, upgrade, backup, restore,
and KEK rotation always name a Silo instance explicitly. The alpha exposes no
platform administration surface. Operators provision and support Silos through
the controlled host console and retain an external change record.
The process-local trigger queue is not an accepted-work queue in the Silo
alpha. A busy project or instance rejects new work explicitly. Each instance
has checked-in application limits and explicit systemd CPU, memory, and process
ceilings. Workspace filesystem quotas are a host provisioning prerequisite.
This topology changes deployment, not the semantic tenant root:
`Organization` remains the tenant root and every authorization, secret, audit,
Project, Team, and Run remains Organization-scoped. A later pooled or bridge
adapter may host multiple Organizations only after ADR-0022's durable admission
and platform control requirements are implemented.
## Consequences
- Cross-Organization data access is prevented by database credentials and
process/filesystem identity in addition to application authorization.
- Provisioning, migrations, backups, health, and upgrades are repeated per
Silo and therefore must be automated before tenant count grows materially.
- Cross-Organization fairness is not applicable inside one Silo; host cgroup
ceilings prevent one Silo from exhausting the machine.
- Database/workspace backups and the keyring/environment recovery copy are
produced to distinct protected destinations and must pass a restore drill.
## Deferred
- Shared platform identity and administration from ADR-0023.
- Pooled durable fair scheduling from ADR-0022.
- Customer org administration SPA and self-service onboarding.
- Docker or Kubernetes as an alternative deployment adapter.
+13 -5
View File
@@ -18,18 +18,26 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7" # ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6" # ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
# Agent run policy (optional). Defaults to 25. # Alpha Silo safety limits. max turns may use its default; every other value is
# mandatory in production and should be calibrated on the target host.
# HUB_AGENT_MAX_TURNS=25 # HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400"
HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120"
# Persistent system-managed root for project workspaces. Production must use an # Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults # absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Feishu (lark) bot credentials. # This process is pinned to exactly one Organization. Feishu credentials are
FEISHU_APP_ID="" # resolved from that Organization's encrypted ACTIVE connection.
FEISHU_APP_SECRET="" HUB_SILO_ORGANIZATION_ID=""
FEISHU_BOT_OPEN_ID="" HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires # Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed. # the file to be executable and `cph --version` to succeed.
+126 -76
View File
@@ -1,101 +1,151 @@
# Hub service installation # Alpha Silo service installation
The initial production target is a supported Linux host with systemd, The supervised alpha runs one Organization per named Silo. The supported host
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap, has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
create or run the unit as root.
Build or deploy the Hub under `/srv/curriculum-project-hub`, then run: Each Silo needs its own logical database/role, instance id, service user,
environment, keyring, workspace, port, domain, and host filesystem quota.
Application code lives in immutable versioned directories under
`/srv/curriculum-project-hub/releases/`; releases contain no customer state.
## Install an instance
```sh ```sh
sudo BASE=/srv/curriculum-project-hub \ sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/hub \ HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
bash /srv/curriculum-project-hub/hub/deploy/install_service.sh INSTANCE_ID=org-a \
PORT=8788 \
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
``` ```
On a clean host the first invocation creates The first invocation creates root-owned `0600` files below
`/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned `/srv/curriculum-project-hub/.secrets/org-a/` and exits 78. Copy the keyring to
`secret-keyring.json`, then exits with status 78. Copy the keyring to a a separately protected recovery destination and fill every blank in
separately protected recovery location before continuing; it must not live only `platform.env`. Before rerunning the installer, migrate the empty Silo database:
on this host or in the ordinary database/workspace backup. Fill every required
environment blank, set `CPH_BIN` to an executable compatible `cph`, keep both
source files root-only at mode `0600`, and run the same command again. No
systemd unit is installed until configuration,
paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory
ownership all pass preflight.
The service account cannot read the keyring source. The installed unit uses
systemd `LoadCredential` to materialize a read-only `cph-secret-keyring` for the
running service. Hub refuses production startup if this credential is missing,
malformed, or does not contain its declared active key. Provider credentials
are configured through the Organization admin connection API, never through
process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings.
Rotate the local KEK only from the controlled host console. The command first
atomically adds a new active key while retaining every previous key, then
authenticates and rewraps every stored Provider and Feishu Application DEK,
writes redacted org audit events, and verifies the complete set again:
```sh ```sh
sudo bash -c ' sudo bash -c '
set -euo pipefail set -euo pipefail
systemctl stop cph-hub.service set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
set -a node /srv/curriculum-project-hub/releases/<release-id>/hub/node_modules/prisma/build/index.js \
. /srv/curriculum-project-hub/.secrets/platform.env migrate deploy \
set +a --schema /srv/curriculum-project-hub/releases/<release-id>/hub/prisma/schema.prisma
node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \
--keyring-file /srv/curriculum-project-hub/.secrets/secret-keyring.json
systemctl start cph-hub.service
' '
``` ```
The CLI refuses to rotate while `cph-hub.service` is active; stopping the only Then rerun the installer so it can complete preflight and install the stopped
writer prevents a concurrent BYOK version from being appended under the old unit. Resource numbers above are examples, not defaults; the operator must set
in-memory keyring. A PostgreSQL transaction advisory lock excludes a second measured ceilings explicitly.
rotation and is released automatically if the CLI or host crashes. The
operation is therefore rerunnable after interruption: old and new keys remain
in the atomically replaced keyring, so partially rewrapped rows stay
decryptable. It also refuses success while any envelope still names a
non-active KEK. Back up the updated keyring to the separate recovery location
and complete a restore preflight before considering old-key retirement. The
pilot deliberately does not auto-delete previous keys.
The deployment path also runs `npm run audit:production` from the locked clean The installer refuses overlapping release/persistent paths, root service users,
install before building or restarting the service. A current high or critical missing Node/cph/PostgreSQL/bubblewrap prerequisites, invalid credentials, and
production advisory therefore blocks deployment instead of becoming a warning unreachable database roles. The installed unit is `cph-hub-org-a.service` and
buried in CI output. uses systemd `LoadCredential`; the service user cannot read the source keyring.
An existing service account is accepted only when its primary group, home and Default state paths are:
non-login shell match the requested configuration and it has no supplementary
groups. After provisioning, the installer executes path-access, built Hub,
Prisma, an authenticated database query, `cph`, `socat` and bubblewrap namespace probes
as that account with `no_new_privs` set. Inaccessible parents, UID-specific
database/network policy, or a host that forbids unprivileged user namespaces
therefore fail before unit installation.
The default persistent layout is deliberately outside every source/release
tree:
```text ```text
/var/lib/cph-hub/home /var/lib/cph-hub/org-a/home
/var/lib/cph-hub/state /var/lib/cph-hub/org-a/state
/var/lib/cph-hub/workspaces /var/lib/cph-hub/org-a/workspaces
/var/cache/cph-hub /var/cache/cph-hub/org-a
``` ```
Custom paths are supported with `SERVICE_HOME`, `STATE_DIR`, `CACHE_DIR` and ## Bootstrap the only Organization
`WORKSPACE_ROOT`, but all must be absolute. The installer rejects any persistent
path that is equal to, above, or below `BASE`; this keeps rsync, release
switching, rollback and pruning unable to traverse customer workspaces. Set the
same custom workspace in `HUB_PROJECT_WORKSPACE_ROOT` inside `platform.env`.
After a successful installation: Prepare a root-owned `0600` JSON file containing
`organization`, `owner`, `feishu`, `provider`, and optionally `teams`. Secrets
never appear in command-line arguments.
```json
{
"organization": { "id": "org_a", "slug": "org-a", "name": "Org A" },
"owner": { "openId": "ou_owner", "displayName": "Owner" },
"feishu": {
"appId": "cli_xxx",
"appSecret": "...",
"botOpenId": "ou_bot"
},
"provider": {
"providerId": "openrouter",
"baseUrl": "https://openrouter.ai/api",
"authToken": "..."
},
"teams": [{ "slug": "teachers", "name": "Teachers" }]
}
```
```sh ```sh
sudo systemctl start cph-hub.service sudo bash -c '
sudo systemctl status cph-hub.service set -euo pipefail
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
node /srv/curriculum-project-hub/releases/<release-id>/hub/dist/deployment/bootstrap-silo-cli.js \
--config-file /root/org-a-bootstrap.json \
--keyring-file /srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json
'
``` ```
`HOST` and `PORT` in `platform.env` are passed to the real HTTP listener. The Bootstrap encrypts and activates both connection records and is rerunnable when
default `127.0.0.1:8788` expects a local TLS reverse proxy; the public OAuth URL provider configuration fails after Organization creation. Hub startup refuses
must be an HTTPS `HUB_PUBLIC_BASE_URL`. to become healthy unless the sole Organization, active provider, active Feishu
application, and Feishu listener are ready. Delete the bootstrap input after
the recovery copy is current. Production has no process-global Feishu/provider
credential fallback.
## Start and rotate
```sh
sudo systemctl start cph-hub-org-a.service
sudo systemctl status cph-hub-org-a.service
```
KEK rotation requires the named unit to be stopped. Source its instance env so
`HUB_SYSTEMD_UNIT` and `DATABASE_URL` identify the same Silo:
```sh
sudo bash -c '
set -euo pipefail
systemctl stop cph-hub-org-a.service
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \
--keyring-file /srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json
systemctl start cph-hub-org-a.service
'
```
## Backup and restore drill (recommended after the demo is running)
Stop the unit and run `backup_silo.sh` with distinct root-owned destinations:
```sh
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
KEYRING_FILE=/srv/curriculum-project-hub/.secrets/org-a/secret-keyring.json \
BUSINESS_BACKUP_DIR=/backup/business \
RECOVERY_BACKUP_DIR=/separate-recovery \
bash hub/deploy/backup_silo.sh
```
The business set contains the PostgreSQL custom dump and workspace archive. The
separate recovery set contains the keyring and environment. Both include
checksums; neither destination may be the live host's only disk.
Restore into a separate drill database/workspace, verify checksums, then run:
```sh
set -a; . /path/to/restored/platform.env; set +a
node hub/dist/deployment/restore-preflight.js \
--keyring-file /path/to/restored/secret-keyring.json
```
Traffic stays disabled until the sole Organization and every Feishu/provider
envelope authenticate, the workspace exists, and an end-to-end test run passes.
For the first supervised demo, install → bootstrap → start → health check is the
deployment gate. A completed off-host restore drill is an Alpha hardening item,
not a prerequisite for bringing up that first controlled Silo.
The default bind remains loopback; expose it only through a TLS reverse proxy.
The platform admin surface is not part of the alpha and must not be exposed.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Create a stopped-instance backup. Business data and recovery credentials are
# deliberately written to different root-owned destinations.
set -euo pipefail
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}"
ENV_FILE="${ENV_FILE:?ENV_FILE required}"
KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}"
BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
[ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
umask 077
validate_root_secret_file() {
local label="$1" path="$2" metadata
[[ "$path" = /* ]] || { echo "$label must be an absolute path" >&2; exit 1; }
[ ! -L "$path" ] || { echo "$label must not be a symlink: $path" >&2; exit 1; }
[ -f "$path" ] || { echo "$label must be a regular file: $path" >&2; exit 1; }
metadata="$(stat -c '%u:%g:%a' -- "$path")"
[ "$metadata" = "0:0:600" ] || {
echo "$label must be root:root mode 0600; found $metadata" >&2
exit 1
}
}
validate_root_secret_file "ENV_FILE" "$ENV_FILE"
validate_root_secret_file "KEYRING_FILE" "$KEYRING_FILE"
state="$(systemctl show --property=ActiveState --value "$SERVICE_UNIT")"
case "$state" in inactive|failed) ;; *) echo "$SERVICE_UNIT must be stopped; state=$state" >&2; exit 1;; esac
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}"
[ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; }
install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
paths_overlap() {
local left="$1" right="$2"
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
}
for pair in \
"$business_root|$recovery_root" \
"$business_root|$workspace_root" \
"$recovery_root|$workspace_root" \
"$recovery_root|$secret_root"; do
left="${pair%%|*}"; right="${pair#*|}"
if paths_overlap "$left" "$right"; then
echo "backup destinations must not overlap each other or live state: $left <> $right" >&2
exit 1
fi
done
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
business="$business_root/$INSTANCE_ID-$stamp"
recovery="$recovery_root/$INSTANCE_ID-$stamp"
install -d -o root -g root -m 0700 "$business" "$recovery"
pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL"
tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" .
cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json"
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
(
cd "$business"
sha256sum database.dump workspaces.tar > SHA256SUMS
)
(
cd "$recovery"
sha256sum secret-keyring.json platform.env > SHA256SUMS
)
echo "business backup: $business"
echo "separate recovery backup: $recovery"
+5 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Curriculum Project Hub (Feishu agent + cph) Description=Curriculum Project Hub Silo (__INSTANCE_ID__)
After=network-online.target postgresql.service After=network-online.target postgresql.service
Wants=network-online.target Wants=network-online.target
# ADR-0018: refuse to start when the configured OS sandbox binary disappears # ADR-0018: refuse to start when the configured OS sandbox binary disappears
@@ -28,6 +28,10 @@ ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
MemoryMax=__MEMORY_MAX__
CPUQuota=__CPU_QUOTA__
TasksMax=__TASKS_MAX__
OOMPolicy=stop
# Graceful shutdown: lark ws close + in-flight runs. # Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 TimeoutStopSec=30
+40 -16
View File
@@ -5,51 +5,75 @@
# PLATFORM_DEPLOY_HOST required # PLATFORM_DEPLOY_HOST required
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user) # PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
# PLATFORM_DEPLOY_USER optional, defaults to deploy # PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22 # PLATFORM_DEPLOY_PORT optional SSH port, defaults to 22
# PLATFORM_DEPLOY_HUB_PORT required Silo listener port
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id
# PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz # PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools # The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools
# (`pg_isready`), systemd, bubblewrap (`bwrap`) and `socat` (for the ADR-0018 agent sandbox), # (`pg_isready`), systemd, bubblewrap (`bwrap`) and `socat` (for the ADR-0018 agent sandbox),
# and a compatible `cph` binary named by platform.env. The service installer # and a compatible `cph` binary named by platform.env. The service installer
# provisions the dedicated non-root identity and persistent directories. # provisions the dedicated non-root identity and persistent directories.
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and # Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh. # writing the named /etc/systemd/system/cph-hub-<instance>.service through the installer.
set -euo pipefail set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}" HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}" SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}" DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
PORT="${PLATFORM_DEPLOY_PORT:-22}" PORT="${PLATFORM_DEPLOY_PORT:-22}"
HUB_PORT="${PLATFORM_DEPLOY_HUB_PORT:?PLATFORM_DEPLOY_HUB_PORT required}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
HUB_DIR="$BASE/hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; }
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
TASKS_MAX="${PLATFORM_DEPLOY_TASKS_MAX:?PLATFORM_DEPLOY_TASKS_MAX required}"
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:$HUB_PORT/api/healthz}"
HUB_DIR="$BASE/releases/$RELEASE_ID/hub"
RELEASE_DIR="$BASE/releases/$RELEASE_ID"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new) SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'" release_ready=false
if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then
release_ready=true
elif ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -e '$RELEASE_DIR'"; then
echo "incomplete release already exists: $RELEASE_DIR" >&2
exit 1
else
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
fi
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host). if [ "$release_ready" = false ]; then
rsync -az --delete \ # 1. Populate a new release directory. A completed release is never mutated.
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \ --exclude node_modules --exclude dist --exclude .env \
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps, prove the production dependency tree, then build on the host. # 2. Install deps, audit and build, then atomically mark the release complete.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build" ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart cph-hub.service systemctl restart '$SERVICE_UNIT'
systemctl is-active --quiet cph-hub.service systemctl is-active --quiet '$SERVICE_UNIT'
else else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart cph-hub.service sudo -n systemctl restart '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet cph-hub.service sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi fi
for attempt in \$(seq 1 30); do for attempt in \$(seq 1 30); do
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
+38 -15
View File
@@ -12,16 +12,25 @@ set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}" BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}" HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-cph-hub}" INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID is required (lowercase letters, digits, hyphens)}"
if [[ ! "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]]; then
echo "[install] error: invalid INSTANCE_ID: $INSTANCE_ID" >&2
exit 1
fi
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}"
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/home}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/state}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub}" CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}" PORT="${PORT:?PORT is required and must be unique on the host}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}" KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/$INSTANCE_ID/secret-keyring.json}"
MEMORY_MAX="${MEMORY_MAX:?MEMORY_MAX is required, for example 16G}"
CPU_QUOTA="${CPU_QUOTA:?CPU_QUOTA is required, for example 400%}"
TASKS_MAX="${TASKS_MAX:?TASKS_MAX is required, for example 512}"
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}" SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}" NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}" BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
@@ -108,6 +117,9 @@ for pair in \
"RUNTIME_PATH:$RUNTIME_PATH"; do "RUNTIME_PATH:$RUNTIME_PATH"; do
require_safe_unit_value "${pair%%:*}" "${pair#*:}" require_safe_unit_value "${pair%%:*}" "${pair#*:}"
done done
[[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G"
[[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%"
[[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer"
KEYRING_CREATED=false KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then if [ -L "$KEYRING_FILE" ]; then
@@ -146,9 +158,8 @@ if [ ! -e "$ENV_FILE" ]; then
# rerunning install_service.sh; failed preflight never installs the unit. # rerunning install_service.sh; failed preflight never installs the unit.
NODE_ENV=production NODE_ENV=production
DATABASE_URL= DATABASE_URL=
FEISHU_APP_ID= HUB_SILO_ORGANIZATION_ID=
FEISHU_APP_SECRET= HUB_SYSTEMD_UNIT=$SERVICE_UNIT
FEISHU_BOT_OPEN_ID=
CPH_BIN=$CPH_BIN_DEFAULT CPH_BIN=$CPH_BIN_DEFAULT
HOST=$HOST HOST=$HOST
PORT=$PORT PORT=$PORT
@@ -156,6 +167,13 @@ HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL= HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET= HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=25 HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS=
HUB_AGENT_MAX_RUN_SECONDS=
HUB_HTTP_BODY_LIMIT_BYTES=
HUB_MAX_FILES_PER_MESSAGE=
HUB_MAX_FILE_BYTES=
HUB_HTTP_REQUESTS_PER_MINUTE=
HUB_FEISHU_EVENTS_PER_MINUTE=
HUB_FEISHU_LISTENER_ENABLED=true HUB_FEISHU_LISTENER_ENABLED=true
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE
EOF EOF
@@ -185,6 +203,7 @@ PREFLIGHT_ARGS=(
--state-dir "$STATE_DIR" --state-dir "$STATE_DIR"
--cache-dir "$CACHE_DIR" --cache-dir "$CACHE_DIR"
--workspace-root "$WORKSPACE_ROOT" --workspace-root "$WORKSPACE_ROOT"
--service-unit "$SERVICE_UNIT"
--bwrap-bin "$BWRAP_BIN" --bwrap-bin "$BWRAP_BIN"
--socat-bin "$SOCAT_BIN" --socat-bin "$SOCAT_BIN"
--pg-isready-bin "$PG_ISREADY_BIN" --pg-isready-bin "$PG_ISREADY_BIN"
@@ -267,7 +286,7 @@ provision_directory "$WORKSPACE_ROOT"
# the unit. This catches symlink or ownership changes between the two phases. # the unit. This catches symlink or ownership changes between the two phases.
"${PREFLIGHT_ARGS[@]}" --service-uid "$SERVICE_UID" --service-gid "$SERVICE_GID" "${PREFLIGHT_ARGS[@]}" --service-uid "$SERVICE_UID" --service-gid "$SERVICE_GID"
UNIT="$SYSTEMD_UNIT_DIR/cph-hub.service" UNIT="$SYSTEMD_UNIT_DIR/$SERVICE_UNIT"
install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR" install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR"
TMP_UNIT="$(mktemp)" TMP_UNIT="$(mktemp)"
trap 'rm -f "$TMP_UNIT"' EXIT trap 'rm -f "$TMP_UNIT"' EXIT
@@ -285,11 +304,15 @@ sed \
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \ -e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
-e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \ -e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \ -e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
-e "s|__INSTANCE_ID__|$INSTANCE_ID|g" \
-e "s|__MEMORY_MAX__|$MEMORY_MAX|g" \
-e "s|__CPU_QUOTA__|$CPU_QUOTA|g" \
-e "s|__TASKS_MAX__|$TASKS_MAX|g" \
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT" "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload systemctl daemon-reload
systemctl enable cph-hub.service systemctl enable "$SERVICE_UNIT"
echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP" echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT" echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start cph-hub.service" echo "[install] start with: systemctl start $SERVICE_UNIT"
+1 -1
View File
@@ -24,7 +24,7 @@
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=24"
} }
}, },
"node_modules/@ai-sdk/gateway": { "node_modules/@ai-sdk/gateway": {
+3 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=24"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
@@ -38,6 +38,8 @@
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma", "prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --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",
"secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js", "secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js",
"silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh", "deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
@@ -0,0 +1,30 @@
-- ADR-0024 / Issue 44: Feishu provider-local user identifiers are scoped by
-- the Organization's stable customer application connection. Existing legacy
-- User.feishuOpenId values are deliberately not guessed into this table.
CREATE TABLE "FeishuUserIdentity" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"openId" TEXT NOT NULL,
"unionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeishuUserIdentity_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FeishuUserIdentity_connectionId_openId_key"
ON "FeishuUserIdentity"("connectionId", "openId");
CREATE UNIQUE INDEX "FeishuUserIdentity_connectionId_unionId_key"
ON "FeishuUserIdentity"("connectionId", "unionId");
CREATE UNIQUE INDEX "FeishuUserIdentity_connectionId_userId_key"
ON "FeishuUserIdentity"("connectionId", "userId");
CREATE INDEX "FeishuUserIdentity_userId_idx"
ON "FeishuUserIdentity"("userId");
ALTER TABLE "FeishuUserIdentity"
ADD CONSTRAINT "FeishuUserIdentity_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationFeishuApplicationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FeishuUserIdentity"
ADD CONSTRAINT "FeishuUserIdentity_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+28 -3
View File
@@ -89,7 +89,9 @@ model OrganizationProjectSettings {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
} }
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004). /// A person known to the Hub. `feishuOpenId` remains a legacy compatibility
/// key; new customer-app identities live in FeishuUserIdentity and store a
/// connection-scoped opaque USER principal here instead of a raw open_id.
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
feishuOpenId String @unique feishuOpenId String @unique
@@ -111,6 +113,7 @@ model User {
auditEntries AuditEntry[] @relation("auditActor") auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[]
} }
/// ADR-0024: exactly one customer-owned Feishu application connection may be /// ADR-0024: exactly one customer-owned Feishu application connection may be
@@ -130,10 +133,32 @@ model OrganizationFeishuApplicationConnection {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions") secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
userIdentities FeishuUserIdentity[]
@@index([status]) @@index([status])
} }
/// ADR-0024 / Issue 44: provider-local Feishu identities are never global.
/// The same open_id may identify different people under different customer
/// applications, so all lookup and uniqueness begins with connectionId.
model FeishuUserIdentity {
id String @id @default(cuid())
connectionId String
userId String
openId String
unionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
connection OrganizationFeishuApplicationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([connectionId, openId])
@@unique([connectionId, unionId])
@@unique([connectionId, userId])
@@index([userId])
}
/// ADR-0024: all Feishu app material, including provider-local app/bot ids, is /// ADR-0024: all Feishu app material, including provider-local app/bot ids, is
/// inside one immutable authenticated envelope version. /// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion { model FeishuApplicationCredentialVersion {
@@ -237,8 +262,8 @@ enum PlatformRole {
} }
/// ADR-0019: typed principals for permission grants and actor resolution. /// ADR-0019: typed principals for permission grants and actor resolution.
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external /// USER and Feishu external principals use connection-scoped opaque ids, never
/// principals by their Feishu ids. /// raw provider-local ids; TEAM uses Hub Team.id.
enum PrincipalType { enum PrincipalType {
USER USER
TEAM TEAM
+105 -38
View File
@@ -1,14 +1,16 @@
/** /**
* Feishu web OAuth (user login) for org admin panel. * Feishu web OAuth (user login) for org admin panel.
* *
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot). * Organization login resolves the intended encrypted Feishu Application
* Per-org encrypted Feishu app secrets are deferred (ADR-0021). * Connection before building the authorize URL or exchanging the code.
* *
* Flow: * Flow:
* 1. GET authorize URL → user consents * 1. GET authorize URL → user consents
* 2. callback code → user_access_token (authen/v2/oauth/token) * 2. callback code → user_access_token (authen/v2/oauth/token)
* 3. user_access_token → user info (authen/v1/user_info) → open_id * 3. user_access_token → user info (authen/v1/user_info) → open_id
*/ */
import { classifyNetworkFailure, type NetworkFailureCategory } from "../../connections/networkFailure.js";
export interface FeishuOAuthConfig { export interface FeishuOAuthConfig {
readonly appId: string; readonly appId: string;
@@ -24,10 +26,23 @@ export interface FeishuOAuthConfig {
export interface FeishuOAuthUser { export interface FeishuOAuthUser {
readonly openId: string; readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string; readonly displayName: string;
readonly avatarUrl: string | null; readonly avatarUrl: string | null;
} }
export class FeishuOAuthError extends Error {
constructor(
readonly code: "feishu_oauth_unreachable" | "feishu_oauth_rejected" | "feishu_oauth_invalid_response",
message: string,
readonly category: NetworkFailureCategory | "http" | "provider_rejection" | "invalid_response",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "FeishuOAuthError";
}
}
const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize"; 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_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"; const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
@@ -59,7 +74,9 @@ export async function exchangeCodeForUser(
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> { async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
const fetchImpl = config.fetchImpl ?? fetch; const fetchImpl = config.fetchImpl ?? fetch;
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL; const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
const res = await fetchImpl(tokenUrl, { let res: Response;
try {
res = await fetchImpl(tokenUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ body: JSON.stringify({
@@ -69,56 +86,106 @@ async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: strin
code, code,
redirect_uri: config.redirectUri, redirect_uri: config.redirectUri,
}), }),
redirect: "manual",
signal: AbortSignal.timeout(10_000),
}); });
const body = (await res.json()) as { } catch (error) {
code?: number; throw new FeishuOAuthError(
access_token?: string; "feishu_oauth_unreachable",
error?: string; "Feishu OAuth token exchange could not reach provider",
error_description?: string; classifyNetworkFailure(error),
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; const body = await oauthJson(res, "token exchange");
if (!res.ok || body["code"] !== 0 ||
typeof body["access_token"] !== "string" || body["access_token"] === "") {
throw new FeishuOAuthError(
"feishu_oauth_rejected",
"Feishu OAuth token exchange was rejected",
res.ok ? "provider_rejection" : "http",
res.status,
);
}
return body["access_token"];
} }
async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> { async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> {
const fetchImpl = config.fetchImpl ?? fetch; const fetchImpl = config.fetchImpl ?? fetch;
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL; const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
const res = await fetchImpl(userInfoUrl, { let res: Response;
try {
res = await fetchImpl(userInfoUrl, {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${userAccessToken}` }, headers: { Authorization: `Bearer ${userAccessToken}` },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
}); });
const body = (await res.json()) as { } catch (error) {
code?: number; throw new FeishuOAuthError(
msg?: string; "feishu_oauth_unreachable",
data?: { "Feishu OAuth user info could not reach provider",
open_id?: string; classifyNetworkFailure(error),
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; const body = await oauthJson(res, "user info");
const data = body["data"];
if (!res.ok || body["code"] !== 0 || !isRecord(data)) {
throw new FeishuOAuthError(
"feishu_oauth_rejected",
"Feishu OAuth user info was rejected",
res.ok ? "provider_rejection" : "http",
res.status,
);
}
const openId = data["open_id"];
if (typeof openId !== "string" || openId === "") { if (typeof openId !== "string" || openId === "") {
throw new Error("Feishu user_info response missing open_id"); throw new FeishuOAuthError(
"feishu_oauth_invalid_response",
"Feishu OAuth user info response is missing open_id",
"invalid_response",
res.status,
);
} }
const displayName = const displayName =
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ?? (typeof data["name"] === "string" && data["name"] !== "" ? data["name"] : null) ??
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ?? (typeof data["en_name"] === "string" && data["en_name"] !== "" ? data["en_name"] : null) ??
openId; openId;
const avatar = const avatar =
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== "" (typeof data["avatar_url"] === "string" && data["avatar_url"] !== ""
? body.data.avatar_url ? data["avatar_url"]
: null) ?? : null) ??
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== "" (typeof data["avatar_middle"] === "string" && data["avatar_middle"] !== ""
? body.data.avatar_middle ? data["avatar_middle"]
: null); : null);
return { openId, displayName, avatarUrl: avatar }; const unionId = typeof data["union_id"] === "string" && data["union_id"] !== ""
? data["union_id"]
: undefined;
return { openId, ...(unionId !== undefined ? { unionId } : {}), displayName, avatarUrl: avatar };
}
async function oauthJson(response: Response, operation: string): Promise<Record<string, unknown>> {
let value: unknown;
try {
value = await response.json();
} catch {
throw new FeishuOAuthError(
"feishu_oauth_invalid_response",
`Feishu OAuth ${operation} returned an invalid response`,
"invalid_response",
response.status,
);
}
if (!isRecord(value)) {
throw new FeishuOAuthError(
"feishu_oauth_invalid_response",
`Feishu OAuth ${operation} returned an invalid response`,
"invalid_response",
response.status,
);
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
} }
+41 -1
View File
@@ -16,6 +16,8 @@ export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADM
export interface AuthContext { export interface AuthContext {
readonly session: SessionPayload; readonly session: SessionPayload;
readonly user: User; readonly user: User;
readonly feishuOpenId: string;
readonly authenticationOrganizationId?: string | undefined;
} }
export interface OrgAuthContext extends AuthContext { export interface OrgAuthContext extends AuthContext {
@@ -55,12 +57,45 @@ export async function requireSession(
await sendError(reply, 401, "unauthenticated", "session invalid or expired"); await sendError(reply, 401, "unauthenticated", "session invalid or expired");
return null; return null;
} }
if ("feishuIdentityId" in session) {
const identity = await deps.prisma.feishuUserIdentity.findUnique({
where: { id: session.feishuIdentityId },
include: {
user: true,
connection: {
select: {
id: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
organization: { select: { id: true, status: true } },
},
},
},
});
if (identity === null || identity.userId !== session.userId ||
identity.connectionId !== session.feishuConnectionId ||
identity.connection.organization.id !== session.feishuOrganizationId ||
identity.connection.status !== "ACTIVE" ||
identity.connection.activeSecretVersion === null ||
identity.connection.activeSecretVersion.connectionId !== identity.connection.id ||
identity.connection.activeSecretVersion.retiredAt !== null ||
identity.connection.organization.status !== "ACTIVE") {
await sendError(reply, 401, "unauthenticated", "session identity not found or inactive");
return null;
}
return {
session,
user: identity.user,
feishuOpenId: identity.openId,
authenticationOrganizationId: identity.connection.organization.id,
};
}
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } }); const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
if (user === null || user.feishuOpenId !== session.feishuOpenId) { if (user === null || user.feishuOpenId !== session.feishuOpenId) {
await sendError(reply, 401, "unauthenticated", "session user not found"); await sendError(reply, 401, "unauthenticated", "session user not found");
return null; return null;
} }
return { session, user }; return { session, user, feishuOpenId: user.feishuOpenId };
} }
export async function requireOrgRole( export async function requireOrgRole(
@@ -87,6 +122,11 @@ export async function requireOrgRole(
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`); await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
return null; return null;
} }
if (auth.authenticationOrganizationId !== undefined &&
auth.authenticationOrganizationId !== organization.id) {
await sendError(reply, 403, "forbidden", "session is scoped to a different organization");
return null;
}
const membership = await deps.prisma.organizationMembership.findFirst({ const membership = await deps.prisma.organizationMembership.findFirst({
where: { where: {
+60 -15
View File
@@ -15,28 +15,41 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
/** OAuth state cookie TTL: 10 minutes. */ /** OAuth state cookie TTL: 10 minutes. */
export const OAUTH_STATE_TTL_SECONDS = 10 * 60; export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
export interface SessionPayload { interface SessionTimes {
readonly userId: string;
readonly feishuOpenId: string;
readonly iat: number; readonly iat: number;
readonly exp: number; readonly exp: number;
} }
export type SessionIdentity =
| {
readonly userId: string;
readonly feishuOpenId: string;
}
| {
readonly userId: string;
readonly feishuIdentityId: string;
readonly feishuConnectionId: string;
readonly feishuOrganizationId: string;
};
export type SessionPayload = SessionIdentity & SessionTimes;
export interface OAuthStatePayload { export interface OAuthStatePayload {
readonly nonce: string; readonly nonce: string;
readonly returnTo: string; readonly returnTo: string;
readonly organizationId?: string | undefined;
readonly connectionId?: string | undefined;
readonly exp: number; readonly exp: number;
} }
export function signSession( export function signSession(
payload: Omit<SessionPayload, "iat" | "exp">, payload: SessionIdentity,
secret: string, secret: string,
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS, ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000), nowSeconds: number = Math.floor(Date.now() / 1000),
): string { ): string {
const full: SessionPayload = { const full: SessionPayload = {
userId: payload.userId, ...payload,
feishuOpenId: payload.feishuOpenId,
iat: nowSeconds, iat: nowSeconds,
exp: nowSeconds + ttlSeconds, exp: nowSeconds + ttlSeconds,
}; };
@@ -48,15 +61,38 @@ export function verifySession(
secret: string, secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000), nowSeconds: number = Math.floor(Date.now() / 1000),
): SessionPayload | null { ): SessionPayload | null {
const payload = verifyJson<SessionPayload>(token, secret); const payload = verifyJson<Record<string, unknown>>(token, secret);
if (payload === null) return null; if (payload === null) return null;
if (typeof payload.userId !== "string" || payload.userId === "") 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 (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null; if (payload["exp"] <= nowSeconds) return null;
if (payload.exp <= nowSeconds) return null; const legacy = typeof payload["feishuOpenId"] === "string" && payload["feishuOpenId"] !== "";
return payload; const scoped = typeof payload["feishuIdentityId"] === "string" && payload["feishuIdentityId"] !== "" &&
typeof payload["feishuConnectionId"] === "string" && payload["feishuConnectionId"] !== "" &&
typeof payload["feishuOrganizationId"] === "string" && payload["feishuOrganizationId"] !== "";
if (legacy === scoped) return null;
if (legacy) {
return {
userId: payload["userId"],
feishuOpenId: payload["feishuOpenId"] as string,
iat: payload["iat"],
exp: payload["exp"],
};
}
return {
userId: payload["userId"],
feishuIdentityId: payload["feishuIdentityId"] as string,
feishuConnectionId: payload["feishuConnectionId"] as string,
feishuOrganizationId: payload["feishuOrganizationId"] as string,
iat: payload["iat"],
exp: payload["exp"],
};
} }
/*
* OAuth state may be legacy/unscoped or bind both an Organization and its
* intended Feishu connection. A half-scoped state is always invalid.
*/
export function signOAuthState( export function signOAuthState(
payload: Omit<OAuthStatePayload, "exp">, payload: Omit<OAuthStatePayload, "exp">,
secret: string, secret: string,
@@ -64,8 +100,7 @@ export function signOAuthState(
nowSeconds: number = Math.floor(Date.now() / 1000), nowSeconds: number = Math.floor(Date.now() / 1000),
): string { ): string {
const full: OAuthStatePayload = { const full: OAuthStatePayload = {
nonce: payload.nonce, ...payload,
returnTo: payload.returnTo,
exp: nowSeconds + ttlSeconds, exp: nowSeconds + ttlSeconds,
}; };
return signJson(full, secret); return signJson(full, secret);
@@ -81,7 +116,17 @@ export function verifyOAuthState(
if (typeof payload.nonce !== "string" || payload.nonce === "") return null; if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
if (typeof payload.returnTo !== "string") return null; if (typeof payload.returnTo !== "string") return null;
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null; if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
return payload; const hasOrganization = typeof payload.organizationId === "string" && payload.organizationId !== "";
const hasConnection = typeof payload.connectionId === "string" && payload.connectionId !== "";
if (hasOrganization !== hasConnection) return null;
return {
nonce: payload.nonce,
returnTo: payload.returnTo,
...(hasOrganization
? { organizationId: payload.organizationId!, connectionId: payload.connectionId! }
: {}),
exp: payload.exp,
};
} }
function signJson(value: unknown, secret: string): string { function signJson(value: unknown, secret: string): string {
+5
View File
@@ -23,6 +23,7 @@ export interface AdminPluginConfig {
readonly cookieSecure?: boolean; readonly cookieSecure?: boolean;
readonly oauthScope?: string; readonly oauthScope?: string;
readonly fetchImpl?: typeof fetch; readonly fetchImpl?: typeof fetch;
readonly allowLegacyFeishuOAuth?: boolean;
} }
export async function registerAdminPlugin( export async function registerAdminPlugin(
@@ -40,9 +41,13 @@ export async function registerAdminPlugin(
publicBaseUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl,
feishuAppId: config.feishuAppId, feishuAppId: config.feishuAppId,
feishuAppSecret: config.feishuAppSecret, feishuAppSecret: config.feishuAppSecret,
secretEnvelope: config.secretEnvelope,
cookieSecure, cookieSecure,
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}), ...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
...(config.allowLegacyFeishuOAuth !== undefined
? { allowLegacyFeishuOAuth: config.allowLegacyFeishuOAuth }
: {}),
}); });
await registerOrgRoutes(app, { await registerOrgRoutes(app, {
+165 -24
View File
@@ -4,10 +4,14 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { import {
buildAuthorizeUrl, buildAuthorizeUrl,
DEFAULT_OAUTH_SCOPE, DEFAULT_OAUTH_SCOPE,
exchangeCodeForUser, exchangeCodeForUser,
FeishuOAuthError,
type FeishuOAuthConfig, type FeishuOAuthConfig,
} from "../auth/feishuOAuth.js"; } from "../auth/feishuOAuth.js";
import { import {
@@ -21,6 +25,7 @@ import {
signOAuthState, signOAuthState,
signSession, signSession,
verifyOAuthState, verifyOAuthState,
type SessionIdentity,
} from "../auth/session.js"; } from "../auth/session.js";
import { handleRouteError } from "../errors.js"; import { handleRouteError } from "../errors.js";
@@ -30,15 +35,17 @@ export interface AuthRouteConfig {
readonly publicBaseUrl: string; readonly publicBaseUrl: string;
readonly feishuAppId: string; readonly feishuAppId: string;
readonly feishuAppSecret: string; readonly feishuAppSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly oauthScope?: string; readonly oauthScope?: string;
readonly cookieSecure: boolean; readonly cookieSecure: boolean;
readonly fetchImpl?: typeof fetch; readonly fetchImpl?: typeof fetch;
readonly allowLegacyFeishuOAuth?: boolean;
} }
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> { export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`; const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
const oauthConfig: FeishuOAuthConfig = { const legacyOauthConfig: FeishuOAuthConfig = {
appId: config.feishuAppId, appId: config.feishuAppId,
appSecret: config.feishuAppSecret, appSecret: config.feishuAppSecret,
redirectUri, redirectUri,
@@ -46,6 +53,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
}; };
if (config.allowLegacyFeishuOAuth === true) {
app.get("/auth/feishu", async (request, reply) => { app.get("/auth/feishu", async (request, reply) => {
try { try {
const returnTo = sanitizeReturnTo( const returnTo = sanitizeReturnTo(
@@ -55,20 +63,55 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
); );
const nonce = randomBytes(16).toString("hex"); const nonce = randomBytes(16).toString("hex");
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret); const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit. setOAuthNonceCookie(reply, nonce, config.cookieSecure);
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, { const url = buildAuthorizeUrl(legacyOauthConfig, stateToken);
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
maxAge: 600,
});
const url = buildAuthorizeUrl(oauthConfig, stateToken);
return reply.redirect(url); return reply.redirect(url);
} catch (err) { } catch (err) {
return handleRouteError(reply, err); return handleRouteError(reply, err);
} }
}); });
}
app.get("/auth/feishu/:orgSlug", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const organization = await config.prisma.organization.findUnique({
where: { slug: orgSlug },
select: { id: true, status: true },
});
if (organization === null) throw new HttpError(404, "org_not_found", `organization not found: ${orgSlug}`);
if (organization.status !== "ACTIVE") {
throw new HttpError(403, "org_not_active", `organization is ${organization.status}`);
}
const credential = await resolveActiveFeishuApplication(
config.prisma,
config.secretEnvelope,
{ organizationId: organization.id },
);
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,
organizationId: organization.id,
connectionId: credential.connectionId,
}, config.sessionSecret);
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
return reply.redirect(buildAuthorizeUrl({
appId: credential.appId,
appSecret: credential.appSecret,
redirectUri,
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
}, stateToken));
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/auth/feishu/callback", async (request, reply) => { app.get("/auth/feishu/callback", async (request, reply) => {
try { try {
@@ -96,7 +139,49 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
} }
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" }); reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
let oauthConfig = legacyOauthConfig;
if (statePayload.connectionId === undefined && config.allowLegacyFeishuOAuth !== true) {
throw new HttpError(400, "bad_request", "unscoped Feishu OAuth is disabled");
}
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const credential = await resolveActiveFeishuApplication(
config.prisma,
config.secretEnvelope,
{ connectionId: statePayload.connectionId },
);
if (credential.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth state Organization scope mismatch");
}
oauthConfig = {
appId: credential.appId,
appSecret: credential.appSecret,
redirectUri,
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
};
}
const feishuUser = await exchangeCodeForUser(oauthConfig, code); const feishuUser = await exchangeCodeForUser(oauthConfig, code);
let userId: string;
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const identity = await upsertScopedFeishuIdentity(config.prisma, {
connectionId: statePayload.connectionId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
});
if (identity.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
}
userId = identity.userId;
setSessionCookie(reply, config, {
userId,
feishuIdentityId: identity.identityId,
feishuConnectionId: identity.connectionId,
feishuOrganizationId: identity.organizationId,
});
} else {
const user = await config.prisma.user.upsert({ const user = await config.prisma.user.upsert({
where: { feishuOpenId: feishuUser.openId }, where: { feishuOpenId: feishuUser.openId },
create: { create: {
@@ -109,20 +194,38 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
avatarUrl: feishuUser.avatarUrl, avatarUrl: feishuUser.avatarUrl,
}, },
}); });
userId = user.id;
setSessionCookie(reply, config, { setSessionCookie(reply, config, {
userId: user.id, userId,
feishuOpenId: user.feishuOpenId, feishuOpenId: user.feishuOpenId,
}); });
}
const destination = await resolvePostLoginRedirect( const destination = await resolvePostLoginRedirect(
config.prisma, config.prisma,
user.id, userId,
statePayload.returnTo, statePayload.returnTo,
statePayload.organizationId !== undefined
? { intendedOrganizationId: statePayload.organizationId }
: {},
); );
return reply.redirect(destination); return reply.redirect(destination);
} catch (err) { } catch (err) {
request.log.error({ err }, "feishu oauth callback failed"); request.log.error({
requestId: request.id,
operation: "feishu_oauth.callback",
...(err instanceof FeishuOAuthError
? {
errorCode: err.code,
failureCategory: err.category,
...(err.upstreamStatus !== undefined ? { upstreamStatus: err.upstreamStatus } : {}),
}
: {
errorCode: "feishu_oauth_callback_failed",
errorType: err instanceof Error ? err.name : typeof err,
err,
}),
}, "feishu oauth callback failed");
return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`); return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`);
} }
}); });
@@ -138,7 +241,13 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
if (auth === null) return; if (auth === null) return;
const memberships = await config.prisma.organizationMembership.findMany({ const memberships = await config.prisma.organizationMembership.findMany({
where: { userId: auth.user.id, revokedAt: null }, where: {
userId: auth.user.id,
revokedAt: null,
...(auth.authenticationOrganizationId !== undefined
? { organizationId: auth.authenticationOrganizationId }
: {}),
},
select: { select: {
role: true, role: true,
organization: { organization: {
@@ -151,7 +260,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return { return {
user: { user: {
id: auth.user.id, id: auth.user.id,
feishuOpenId: auth.user.feishuOpenId, feishuOpenId: auth.feishuOpenId,
displayName: auth.user.displayName, displayName: auth.user.displayName,
avatarUrl: auth.user.avatarUrl, avatarUrl: auth.user.avatarUrl,
}, },
@@ -170,12 +279,16 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
// Minimal login page until admin SPA lands (PR7). // Minimal login page until admin SPA lands (PR7).
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => { app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
const q = request.query as { error?: string; returnTo?: string }; const q = request.query as { error?: string; returnTo?: string; orgSlug?: string };
const returnTo = sanitizeReturnTo(q.returnTo ?? ""); const returnTo = sanitizeReturnTo(q.returnTo ?? "");
const loginHref = const orgSlug = typeof q.orgSlug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(q.orgSlug)
returnTo === "/admin" ? q.orgSlug
? "/auth/feishu" : null;
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`; const loginHref = orgSlug === null
? null
: returnTo === "/admin"
? `/auth/feishu/${encodeURIComponent(orgSlug)}`
: `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
const errorHtml = const errorHtml =
typeof q.error === "string" && q.error !== "" typeof q.error === "string" && q.error !== ""
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>` ? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
@@ -199,7 +312,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
<h1>Org Admin</h1> <h1>Org Admin</h1>
<p>Sign in with Feishu to manage your organization.</p> <p>Sign in with Feishu to manage your organization.</p>
${errorHtml} ${errorHtml}
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a> ${loginHref === null
? "<p>请从组织专属登录链接进入。</p>"
: `<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>`}
</div> </div>
</body> </body>
</html>`; </html>`;
@@ -210,7 +325,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
export function setSessionCookie( export function setSessionCookie(
reply: FastifyReply, reply: FastifyReply,
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">, config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
identity: { readonly userId: string; readonly feishuOpenId: string }, identity: SessionIdentity,
): void { ): void {
const token = signSession(identity, config.sessionSecret); const token = signSession(identity, config.sessionSecret);
reply.setCookie(SESSION_COOKIE_NAME, token, { reply.setCookie(SESSION_COOKIE_NAME, token, {
@@ -224,7 +339,7 @@ export function setSessionCookie(
/** Exported for tests that need a pre-authenticated cookie value. */ /** Exported for tests that need a pre-authenticated cookie value. */
export function mintSessionToken( export function mintSessionToken(
identity: { readonly userId: string; readonly feishuOpenId: string }, identity: SessionIdentity,
sessionSecret: string, sessionSecret: string,
): string { ): string {
return signSession(identity, sessionSecret); return signSession(identity, sessionSecret);
@@ -238,7 +353,22 @@ async function resolvePostLoginRedirect(
prisma: PrismaClient, prisma: PrismaClient,
userId: string, userId: string,
returnTo: string, returnTo: string,
options: { readonly intendedOrganizationId?: string | undefined } = {},
): Promise<string> { ): Promise<string> {
if (options.intendedOrganizationId !== undefined) {
const intended = await prisma.organizationMembership.findFirst({
where: {
userId,
organizationId: options.intendedOrganizationId,
revokedAt: null,
organization: { status: "ACTIVE" },
},
select: { organization: { select: { slug: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) { if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo; return returnTo;
} }
@@ -267,6 +397,17 @@ async function resolvePostLoginRedirect(
return "/admin/login?error=no_organization"; return "/admin/login?error=no_organization";
} }
function setOAuthNonceCookie(reply: FastifyReply, nonce: string, secure: boolean): void {
// Signed state carries scope/returnTo; the cookie mirrors nonce for double-submit CSRF.
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure,
maxAge: 600,
});
}
/** /**
* Only allow relative same-origin paths under /admin to avoid open redirects. * Only allow relative same-origin paths under /admin to avoid open redirects.
*/ */
+2 -2
View File
@@ -130,7 +130,7 @@ export async function registerExplorerRoutes(
} }
const result = await createOrgProject(config.prisma, { const result = await createOrgProject(config.prisma, {
organizationId: auth.organization.id, organizationId: auth.organization.id,
actorFeishuOpenId: auth.user.feishuOpenId, actorFeishuOpenId: auth.feishuOpenId,
name: body.name, name: body.name,
workspaceRoot: config.projectWorkspaceRoot, workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}), ...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
@@ -219,7 +219,7 @@ export async function registerExplorerRoutes(
return await archiveOrgProjectChatBinding(config.prisma, { return await archiveOrgProjectChatBinding(config.prisma, {
organizationId: auth.organization.id, organizationId: auth.organization.id,
projectId, projectId,
actorFeishuOpenId: auth.user.feishuOpenId, actorFeishuOpenId: auth.feishuOpenId,
}); });
} catch (err) { } catch (err) {
return handleRouteError(reply, err); return handleRouteError(reply, err);
@@ -32,7 +32,7 @@ export interface ResolvedFeishuApplication extends Required<FeishuApplicationCre
readonly organizationId: string; readonly organizationId: string;
} }
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> { export interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
readonly schemaVersion: 1; readonly schemaVersion: 1;
} }
@@ -46,8 +46,8 @@ export class FeishuApplicationConnectionService {
async rotateCustomerApplication( async rotateCustomerApplication(
input: RotateFeishuApplicationInput, input: RotateFeishuApplicationInput,
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> { ): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
const payload = validateCredential(input); const payload = validateFeishuApplicationCredential(input);
const appIdentityFingerprint = fingerprintAppId(payload.appId); const appIdentityFingerprint = fingerprintFeishuAppId(payload.appId);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await requireFeishuAdmin(tx, input); await requireFeishuAdmin(tx, input);
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint); await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
@@ -287,7 +287,7 @@ export function decryptFeishuCredential(
secretVersionId: input.secretVersionId, secretVersionId: input.secretVersionId,
}, envelope); }, envelope);
const validated = validateStoredPayload(payload); const validated = validateStoredPayload(payload);
if (fingerprintAppId(validated.appId) !== input.appIdentityFingerprint) { if (fingerprintFeishuAppId(validated.appId) !== input.appIdentityFingerprint) {
throw new Error("Feishu Application app identity fingerprint mismatch"); throw new Error("Feishu Application app identity fingerprint mismatch");
} }
return validated; return validated;
@@ -387,7 +387,7 @@ export async function rewrapStoredFeishuApplicationEnvelopes(
return { verified, rewrapped }; return { verified, rewrapped };
} }
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 { export function validateFeishuApplicationCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
const appId = nonEmpty(input.appId, "Feishu appId"); const appId = nonEmpty(input.appId, "Feishu appId");
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret"); const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId"); const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId");
@@ -474,7 +474,7 @@ function metadata(
}; };
} }
function fingerprintAppId(appId: string): string { export function fingerprintFeishuAppId(appId: string): string {
return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex"); return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex");
} }
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { lstat, readFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { PrismaClient } from "@prisma/client";
import { bootstrapAlphaSilo, type AlphaSiloBootstrapInput } from "./bootstrap-silo.js";
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { readSiloOrganizationId } from "./silo.js";
const execFileAsync = promisify(execFile);
async function main(): Promise<void> {
if (process.getuid?.() !== 0) throw new Error("Alpha Silo bootstrap must run as root");
await requireStoppedSilo();
const { configFile, keyringFile } = parseArgs(process.argv.slice(2));
await requireRootPrivateFile(configFile, "bootstrap config");
await requireRootPrivateFile(keyringFile, "secret keyring");
const databaseUrl = process.env["DATABASE_URL"]?.trim();
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
const input = parseConfig(await readFile(configFile, "utf8"));
const configuredOrganizationId = readSiloOrganizationId();
if (input.organization.id !== configuredOrganizationId) {
throw new Error(
`bootstrap Organization ${input.organization.id} does not match HUB_SILO_ORGANIZATION_ID ${configuredOrganizationId}`,
);
}
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
try {
const result = await bootstrapAlphaSilo(prisma, secrets, input);
console.log(JSON.stringify(result));
} finally {
await prisma.$disconnect();
}
}
async function requireStoppedSilo(): Promise<void> {
const unit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
if (unit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(unit)) {
throw new Error("HUB_SYSTEMD_UNIT must name the Silo being bootstrapped");
}
let state: string;
try {
const result = await execFileAsync("systemctl", ["show", "--property=ActiveState", "--value", unit], {
timeout: 10_000,
});
state = result.stdout.trim();
} catch (error) {
throw new Error(`cannot verify that ${unit} is stopped`, { cause: error });
}
if (state !== "inactive" && state !== "failed") {
throw new Error(`${unit} must be stopped before bootstrap; state=${state || "unknown"}`);
}
}
function parseArgs(argv: readonly string[]): { readonly configFile: string; readonly keyringFile: string } {
if (argv.length !== 4 || argv[0] !== "--config-file" || argv[2] !== "--keyring-file" ||
argv[1] === undefined || argv[1] === "" || argv[3] === undefined || argv[3] === "") {
throw new Error("usage: bootstrap-silo --config-file /root/bootstrap.json --keyring-file /root/keyring.json");
}
return { configFile: argv[1], keyringFile: argv[3] };
}
async function requireRootPrivateFile(path: string, label: string): Promise<void> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${label} is not a regular file: ${path}`);
if (metadata.uid !== 0 || (metadata.mode & 0o077) !== 0) {
throw new Error(`${label} must be root-owned and inaccessible by group/others: ${path}`);
}
}
function parseConfig(source: string): AlphaSiloBootstrapInput {
let value: unknown;
try {
value = JSON.parse(source);
} catch (error) {
throw new Error("bootstrap config is not valid JSON", { cause: error });
}
if (!isRecord(value) || !isRecord(value["organization"]) || !isRecord(value["owner"]) ||
!isRecord(value["feishu"]) || !isRecord(value["provider"])) {
throw new Error("bootstrap config must contain organization, owner, feishu, and provider objects");
}
const organization = value["organization"];
const owner = value["owner"];
const feishu = value["feishu"];
const provider = value["provider"];
const teams = value["teams"];
if (teams !== undefined && !Array.isArray(teams)) throw new Error("bootstrap teams must be an array");
return {
organization: {
id: stringField(organization, "id"),
slug: stringField(organization, "slug"),
name: stringField(organization, "name"),
},
owner: {
openId: stringField(owner, "openId"),
displayName: stringField(owner, "displayName"),
...(optionalStringField(owner, "unionId") !== undefined
? { unionId: optionalStringField(owner, "unionId")! }
: {}),
},
feishu: {
appId: stringField(feishu, "appId"),
appSecret: stringField(feishu, "appSecret"),
botOpenId: stringField(feishu, "botOpenId"),
...(optionalStringField(feishu, "verificationToken") !== undefined
? { verificationToken: optionalStringField(feishu, "verificationToken")! }
: {}),
...(optionalStringField(feishu, "encryptKey") !== undefined
? { encryptKey: optionalStringField(feishu, "encryptKey")! }
: {}),
},
provider: {
providerId: stringField(provider, "providerId"),
baseUrl: stringField(provider, "baseUrl"),
authToken: stringField(provider, "authToken"),
...(optionalStringField(provider, "anthropicApiKey") !== undefined
? { anthropicApiKey: optionalStringField(provider, "anthropicApiKey")! }
: {}),
},
...(Array.isArray(teams)
? {
teams: teams.map((team, index) => {
if (!isRecord(team)) throw new Error(`bootstrap teams[${index}] must be an object`);
return {
slug: stringField(team, "slug"),
name: stringField(team, "name"),
...(optionalStringField(team, "description") !== undefined
? { description: optionalStringField(team, "description")! }
: {}),
};
}),
}
: {}),
};
}
function stringField(value: Record<string, unknown>, name: string): string {
const field = value[name];
if (typeof field !== "string" || field.trim() === "") throw new Error(`bootstrap ${name} is required`);
return field;
}
function optionalStringField(value: Record<string, unknown>, name: string): string | undefined {
const field = value[name];
if (field === undefined) return undefined;
if (typeof field !== "string") throw new Error(`bootstrap ${name} must be a string`);
return field;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
main().catch((error: unknown) => {
console.error(`[bootstrap-silo] failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
+365
View File
@@ -0,0 +1,365 @@
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import {
fingerprintFeishuAppId,
validateFeishuApplicationCredential,
type FeishuApplicationCredentialInput,
} from "../connections/feishuApplicationConnections.js";
import {
decryptStoredProviderCredential,
ProviderConnectionService,
type ProviderCredentialInput,
} from "../connections/providerConnections.js";
import { probeFeishuApplication, type FeishuReadinessProbe } from "../connections/feishuReadiness.js";
import { probeOpenRouterCredential, type ProviderReadinessProbe } from "../connections/providerReadiness.js";
import { deterministicFeishuUserId, scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
export interface AlphaSiloBootstrapInput {
readonly organization: { readonly id: string; readonly slug: string; readonly name: string };
readonly owner: { readonly openId: string; readonly displayName: string; readonly unionId?: string };
readonly feishu: FeishuApplicationCredentialInput;
readonly provider: ProviderCredentialInput & { readonly providerId: string };
readonly teams?: readonly {
readonly slug: string;
readonly name: string;
readonly description?: string;
}[];
}
export interface AlphaSiloBootstrapResult {
readonly organizationId: string;
readonly ownerUserId: string;
readonly feishuConnectionId: string;
readonly initialized: boolean;
readonly providerConfigured: boolean;
}
/**
* Idempotently bootstrap the one Organization allowed in an Alpha Silo.
* Secrets are accepted only in-memory and persisted as authenticated envelopes.
*/
export async function bootstrapAlphaSilo(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
input: AlphaSiloBootstrapInput,
probes: {
readonly feishu?: FeishuReadinessProbe;
readonly provider?: ProviderReadinessProbe;
} = {},
): Promise<AlphaSiloBootstrapResult> {
const normalized = validateInput(input);
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
let initialized = false;
let ownerUserId: string;
let feishuConnectionId: string;
if (existing === null) {
await (probes.feishu ?? probeFeishuApplication)(normalized.feishu);
const initializedSilo = await initializeSilo(prisma, secrets, normalized);
initialized = true;
ownerUserId = initializedSilo.ownerUserId;
feishuConnectionId = initializedSilo.feishuConnectionId;
} else {
ownerUserId = existing.ownerUserId;
feishuConnectionId = existing.feishuConnectionId;
const expectedFingerprint = fingerprintFeishuAppId(normalized.feishu.appId);
if (existing.appIdentityFingerprint !== expectedFingerprint) {
throw new Error("bootstrap Feishu app identity does not match the existing Silo");
}
}
const providerService = new ProviderConnectionService(
prisma,
secrets,
probes.provider ?? probeOpenRouterCredential,
);
const existingProviders = await providerService.list(normalized.organization.id);
const existingProvider = existingProviders.find((connection) => connection.providerId === normalized.provider.providerId);
if (existingProvider === undefined) {
await providerService.rotateByok({
organizationId: normalized.organization.id,
actorUserId: ownerUserId,
providerId: normalized.provider.providerId,
baseUrl: normalized.provider.baseUrl,
authToken: normalized.provider.authToken,
...(normalized.provider.anthropicApiKey !== undefined
? { anthropicApiKey: normalized.provider.anthropicApiKey }
: {}),
});
} else if (existingProvider.status !== "ACTIVE" || existingProvider.mode !== "BYOK") {
throw new Error(`bootstrap provider ${normalized.provider.providerId} exists but is not ACTIVE BYOK`);
} else {
const stored = await loadExistingProviderCredential(
prisma,
secrets,
normalized.organization.id,
normalized.provider.providerId,
);
if (stored.baseUrl !== normalized.provider.baseUrl || stored.authToken !== normalized.provider.authToken ||
stored.anthropicApiKey !== (normalized.provider.anthropicApiKey ?? "")) {
throw new Error(`bootstrap provider ${normalized.provider.providerId} credential does not match existing Silo`);
}
}
await ensureBootstrapTeams(prisma, normalized.organization.id, ownerUserId, normalized.teams ?? []);
return {
organizationId: normalized.organization.id,
ownerUserId,
feishuConnectionId,
initialized,
providerConfigured: true,
};
}
async function ensureBootstrapTeams(
prisma: PrismaClient,
organizationId: string,
ownerUserId: string,
teams: NonNullable<AlphaSiloBootstrapInput["teams"]>,
): Promise<void> {
await prisma.$transaction(async (tx) => {
for (const team of teams) {
const existing = await tx.team.findFirst({
where: { organizationId, slug: team.slug, archivedAt: null },
select: { id: true, name: true, description: true },
});
if (existing !== null && (existing.name !== team.name || existing.description !== (team.description ?? null))) {
throw new Error(`bootstrap Team ${team.slug} does not match the existing Silo`);
}
const teamId = existing?.id ?? (await tx.team.create({
data: {
organizationId,
slug: team.slug,
name: team.name,
...(team.description !== undefined ? { description: team.description } : {}),
},
select: { id: true },
})).id;
const membership = await tx.teamMembership.findFirst({
where: { teamId, userId: ownerUserId, revokedAt: null },
select: { id: true },
});
if (membership === null) await tx.teamMembership.create({ data: { teamId, userId: ownerUserId } });
}
});
}
async function initializeSilo(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
input: AlphaSiloBootstrapInput,
): Promise<{ readonly ownerUserId: string; readonly feishuConnectionId: string }> {
const connectionId = randomUUID();
const secretVersionId = randomUUID();
const identityId = randomUUID();
const ownerUserId = deterministicFeishuUserId(connectionId, input.owner.openId);
const appIdentityFingerprint = fingerprintFeishuAppId(input.feishu.appId);
const payload = validateFeishuApplicationCredential(input.feishu);
const envelope = secrets.encryptJson({
purpose: "feishu-application",
organizationId: input.organization.id,
connectionId,
secretVersionId,
}, payload);
await prisma.$transaction(async (tx) => {
const count = await tx.organization.count();
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`);
await tx.organization.create({ data: { ...input.organization } });
await tx.organizationProjectSettings.create({
data: { organizationId: input.organization.id, membersCanCreateProjects: true },
});
await tx.folder.create({
data: {
organizationId: input.organization.id,
name: "Inbox",
sortKey: "000000",
},
});
await tx.user.create({
data: {
id: ownerUserId,
feishuOpenId: scopedFeishuPrincipalId("USER", connectionId, input.owner.openId),
displayName: input.owner.displayName,
},
});
await tx.organizationFeishuApplicationConnection.create({
data: {
id: connectionId,
organizationId: input.organization.id,
appIdentityFingerprint,
status: "DRAFT",
},
});
await tx.feishuUserIdentity.create({
data: {
id: identityId,
connectionId,
userId: ownerUserId,
openId: input.owner.openId,
...(input.owner.unionId !== undefined ? { unionId: input.owner.unionId } : {}),
},
});
await tx.organizationMembership.create({
data: { organizationId: input.organization.id, userId: ownerUserId, role: "OWNER" },
});
for (const team of input.teams ?? []) {
await tx.team.create({
data: {
organizationId: input.organization.id,
slug: team.slug,
name: team.name,
...(team.description !== undefined ? { description: team.description } : {}),
memberships: { create: { userId: ownerUserId } },
},
});
}
await tx.feishuApplicationCredentialVersion.create({
data: {
id: secretVersionId,
connectionId,
version: 1,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: ownerUserId,
},
});
await tx.organizationFeishuApplicationConnection.update({
where: { id: connectionId },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersionId,
activatedAt: new Date(),
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organization.id,
actorUserId: ownerUserId,
action: "alpha_silo.bootstrapped",
metadata: {
connectionId,
ownerIdentityId: identityId,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
},
},
});
});
return { ownerUserId, feishuConnectionId: connectionId };
}
async function loadExistingSilo(
prisma: PrismaClient,
organizationId: string,
ownerOpenId: string,
): Promise<{
readonly ownerUserId: string;
readonly feishuConnectionId: string;
readonly appIdentityFingerprint: string;
} | null> {
const organizations = await prisma.organization.findMany({
take: 2,
include: {
memberships: {
where: { role: "OWNER", revokedAt: null },
take: 2,
include: { user: { include: { feishuIdentities: true } } },
},
feishuApplicationConnection: true,
},
});
if (organizations.length === 0) return null;
if (organizations.length !== 1 || organizations[0]!.id !== organizationId) {
throw new Error("bootstrap database is not the configured single-Organization Silo");
}
const organization = organizations[0]!;
if (organization.status !== "ACTIVE" || organization.memberships.length !== 1 ||
organization.feishuApplicationConnection?.status !== "ACTIVE") {
throw new Error("existing Silo is incomplete or inactive; manual repair is required");
}
const ownerIdentity = organization.memberships[0]!.user.feishuIdentities.find(
(identity) => identity.connectionId === organization.feishuApplicationConnection!.id,
);
if (ownerIdentity?.openId !== ownerOpenId) {
throw new Error("bootstrap owner identity does not match the existing Silo");
}
return {
ownerUserId: organization.memberships[0]!.userId,
feishuConnectionId: organization.feishuApplicationConnection.id,
appIdentityFingerprint: organization.feishuApplicationConnection.appIdentityFingerprint,
};
}
async function loadExistingProviderCredential(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
organizationId: string,
providerId: string,
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string }> {
const connection = await prisma.organizationProviderConnection.findUnique({
where: { organizationId_providerId: { organizationId, providerId } },
include: { activeSecretVersion: true },
});
const version = connection?.activeSecretVersion;
if (connection === null || connection === undefined || version === null || version === undefined ||
connection.status !== "ACTIVE" || version.retiredAt !== null) {
throw new Error(`active provider connection not found: ${providerId}`);
}
return decryptStoredProviderCredential(secrets, {
organizationId,
connectionId: connection.id,
providerId,
secretVersionId: version.id,
envelopeVersion: version.envelopeVersion,
keyId: version.keyId,
envelope: version.envelope,
});
}
function validateInput(input: AlphaSiloBootstrapInput): AlphaSiloBootstrapInput {
const required = (value: string, label: string): string => {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
return normalized;
};
const slug = required(input.organization.slug, "Organization slug");
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) {
throw new Error("Organization slug must be lowercase alphanumeric with optional hyphens");
}
if (input.provider.providerId.trim() !== "openrouter") {
throw new Error("Alpha Silo currently requires providerId=openrouter");
}
return {
organization: {
id: required(input.organization.id, "Organization id"),
slug,
name: required(input.organization.name, "Organization name"),
},
owner: {
openId: required(input.owner.openId, "Owner Feishu openId"),
displayName: required(input.owner.displayName, "Owner displayName"),
...(input.owner.unionId !== undefined ? { unionId: required(input.owner.unionId, "Owner unionId") } : {}),
},
feishu: input.feishu,
provider: input.provider,
...(input.teams !== undefined
? {
teams: input.teams.map((team) => {
const teamSlug = required(team.slug, "Team slug");
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(teamSlug)) {
throw new Error(`invalid Team slug: ${teamSlug}`);
}
return {
slug: teamSlug,
name: required(team.name, `Team ${teamSlug} name`),
...(team.description !== undefined ? { description: team.description.trim() } : {}),
};
}),
}
: {}),
};
}
+7 -2
View File
@@ -32,6 +32,7 @@ interface Options {
readonly stateDir: string; readonly stateDir: string;
readonly cacheDir: string; readonly cacheDir: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly serviceUnit: string;
readonly bwrapBin: string; readonly bwrapBin: string;
readonly socatBin: string; readonly socatBin: string;
readonly pgIsReadyBin: string; readonly pgIsReadyBin: string;
@@ -79,6 +80,7 @@ function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPrefli
stateDir: options.stateDir, stateDir: options.stateDir,
cacheDir: options.cacheDir, cacheDir: options.cacheDir,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
expectedServiceUnit: options.serviceUnit,
env, env,
}; };
} }
@@ -97,6 +99,7 @@ async function canonicalizeInput(input: DeploymentPreflightInput): Promise<Deplo
stateDir, stateDir,
cacheDir, cacheDir,
workspaceRoot, workspaceRoot,
expectedServiceUnit: input.expectedServiceUnit,
env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot }, env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
}; };
} }
@@ -155,8 +158,8 @@ async function validateNodeVersion(nodeBin: string): Promise<void> {
} }
const version = stdout.trim().replace(/^v/, ""); const version = stdout.trim().replace(/^v/, "");
const major = Number(version.split(".")[0]); const major = Number(version.split(".")[0]);
if (!Number.isInteger(major) || major < 20) { if (!Number.isInteger(major) || major < 24) {
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`); throw new Error(`Node.js 24 or newer is required; found ${version || "unknown"}`);
} }
console.log(`[preflight] Node.js: ${version}`); console.log(`[preflight] Node.js: ${version}`);
} }
@@ -479,6 +482,7 @@ function parseArgs(argv: readonly string[]): Options {
"--state-dir", "--state-dir",
"--cache-dir", "--cache-dir",
"--workspace-root", "--workspace-root",
"--service-unit",
"--bwrap-bin", "--bwrap-bin",
"--socat-bin", "--socat-bin",
"--pg-isready-bin", "--pg-isready-bin",
@@ -508,6 +512,7 @@ function parseArgs(argv: readonly string[]): Options {
stateDir: required(values, "--state-dir"), stateDir: required(values, "--state-dir"),
cacheDir: required(values, "--cache-dir"), cacheDir: required(values, "--cache-dir"),
workspaceRoot: required(values, "--workspace-root"), workspaceRoot: required(values, "--workspace-root"),
serviceUnit: required(values, "--service-unit"),
bwrapBin: required(values, "--bwrap-bin"), bwrapBin: required(values, "--bwrap-bin"),
socatBin: required(values, "--socat-bin"), socatBin: required(values, "--socat-bin"),
pgIsReadyBin: required(values, "--pg-isready-bin"), pgIsReadyBin: required(values, "--pg-isready-bin"),
+24 -3
View File
@@ -10,6 +10,7 @@ export interface DeploymentPreflightInput {
readonly stateDir: string; readonly stateDir: string;
readonly cacheDir: string; readonly cacheDir: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly expectedServiceUnit: string;
readonly env: DeploymentEnv; readonly env: DeploymentEnv;
} }
@@ -89,9 +90,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
} }
} }
readRequired(input.env, "FEISHU_APP_ID", problems); readRequired(input.env, "HUB_SILO_ORGANIZATION_ID", problems);
readRequired(input.env, "FEISHU_APP_SECRET", problems); const serviceUnit = readRequired(input.env, "HUB_SYSTEMD_UNIT", problems);
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems); if (serviceUnit !== undefined && !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
problems.push("HUB_SYSTEMD_UNIT must match cph-hub-<instance>.service");
} else if (serviceUnit !== undefined && serviceUnit !== input.expectedServiceUnit) {
problems.push(`HUB_SYSTEMD_UNIT must equal installed unit ${input.expectedServiceUnit}`);
}
const cphBin = readRequired(input.env, "CPH_BIN", problems); const cphBin = readRequired(input.env, "CPH_BIN", problems);
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) { if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
@@ -99,6 +104,7 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
} }
let bind: ServerBinding | undefined; let bind: ServerBinding | undefined;
readRequired(input.env, "PORT", problems);
try { try {
bind = readServerBinding(input.env); bind = readServerBinding(input.env);
} catch (error) { } catch (error) {
@@ -116,6 +122,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
} }
validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems); validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems);
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_CONCURRENT_RUNS", problems);
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_RUN_SECONDS", problems);
readRequiredPositiveInteger(input.env, "HUB_HTTP_BODY_LIMIT_BYTES", problems);
readRequiredPositiveInteger(input.env, "HUB_MAX_FILES_PER_MESSAGE", problems);
readRequiredPositiveInteger(input.env, "HUB_MAX_FILE_BYTES", problems);
readRequiredPositiveInteger(input.env, "HUB_HTTP_REQUESTS_PER_MINUTE", problems);
readRequiredPositiveInteger(input.env, "HUB_FEISHU_EVENTS_PER_MINUTE", problems);
validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems); validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems);
if (problems.length > 0) { if (problems.length > 0) {
@@ -147,6 +160,14 @@ function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, probl
} }
} }
function readRequiredPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
const value = readRequired(env, name, problems);
if (value === undefined) return;
if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
problems.push(`${name} must be a positive integer`);
}
}
function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void { function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void {
const raw = env[name]?.trim().toLowerCase(); const raw = env[name]?.trim().toLowerCase();
if (raw === undefined || raw === "") return; if (raw === undefined || raw === "") return;
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { realpath, stat } from "node:fs/promises";
import { PrismaClient } from "@prisma/client";
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { readSiloOrganizationId, requireSiloOrganization } from "./silo.js";
async function main(): Promise<void> {
const keyringFile = parseArgs(process.argv.slice(2));
const databaseUrl = process.env["DATABASE_URL"]?.trim();
const workspaceRoot = process.env["HUB_PROJECT_WORKSPACE_ROOT"]?.trim();
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
if (workspaceRoot === undefined || workspaceRoot === "") throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required");
const workspace = await realpath(workspaceRoot);
if (!(await stat(workspace)).isDirectory()) throw new Error("restored workspace root is not a directory");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
try {
const organization = await requireSiloOrganization(prisma, readSiloOrganizationId());
const provider = await verifyStoredProviderEnvelopes(prisma, secrets);
const feishu = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
const [activeProviders, activeFeishu] = await Promise.all([
prisma.organizationProviderConnection.count({
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
}),
prisma.organizationFeishuApplicationConnection.count({
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
}),
]);
if (activeProviders < 1) throw new Error("restored Silo has no ACTIVE Provider Connection");
if (activeFeishu !== 1) throw new Error(`restored Silo must have one ACTIVE Feishu Connection; found ${activeFeishu}`);
console.log(JSON.stringify({ organizationId: organization.id, providerEnvelopes: provider, feishuEnvelopes: feishu }));
} finally {
await prisma.$disconnect();
}
}
function parseArgs(argv: readonly string[]): string {
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
throw new Error("usage: restore-preflight --keyring-file /root/recovery/secret-keyring.json");
}
return argv[1];
}
main().catch((error: unknown) => {
console.error(`[restore-preflight] failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
+12 -4
View File
@@ -5,7 +5,6 @@ import { PrismaClient } from "@prisma/client";
import { rotateLocalKek } from "../security/localKekRotation.js"; import { rotateLocalKek } from "../security/localKekRotation.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const SERVICE_UNIT = "cph-hub.service";
async function main(): Promise<void> { async function main(): Promise<void> {
if (process.getuid?.() !== 0) { if (process.getuid?.() !== 0) {
@@ -30,22 +29,31 @@ async function main(): Promise<void> {
} }
async function requireStoppedService(): Promise<void> { async function requireStoppedService(): Promise<void> {
const serviceUnit = readServiceUnit();
let activeState: string; let activeState: string;
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
"systemctl", "systemctl",
["show", "--property=ActiveState", "--value", SERVICE_UNIT], ["show", "--property=ActiveState", "--value", serviceUnit],
{ timeout: 10_000 }, { timeout: 10_000 },
); );
activeState = stdout.trim(); activeState = stdout.trim();
} catch { } catch {
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`); throw new Error(`cannot verify that ${serviceUnit} is stopped`);
} }
if (activeState !== "inactive" && activeState !== "failed") { if (activeState !== "inactive" && activeState !== "failed") {
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`); throw new Error(`${serviceUnit} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
} }
} }
function readServiceUnit(): string {
const serviceUnit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
if (serviceUnit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
throw new Error("HUB_SYSTEMD_UNIT must name this Silo unit (cph-hub-<instance>.service)");
}
return serviceUnit;
}
function parseKeyringFile(argv: readonly string[]): string { function parseKeyringFile(argv: readonly string[]): string {
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") { if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json"); throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
+51
View File
@@ -0,0 +1,51 @@
import type { PrismaClient } from "@prisma/client";
export interface SiloOrganization {
readonly id: string;
readonly slug: string;
readonly name: string;
}
/**
* Read the deployment-pinned Organization identity. A Silo process must never
* infer its tenant from request data or from whichever row happens to exist.
*/
export function readSiloOrganizationId(
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
const organizationId = env["HUB_SILO_ORGANIZATION_ID"]?.trim();
if (organizationId === undefined || organizationId === "") {
throw new Error("HUB_SILO_ORGANIZATION_ID is required");
}
return organizationId;
}
/**
* Prove the database is a single-tenant Silo before accepting traffic.
* Archived rows count too: pointing a Silo process at a pooled or reused
* database is a deployment error, not a condition to paper over.
*/
export async function requireSiloOrganization(
prisma: PrismaClient,
organizationId: string,
): Promise<SiloOrganization> {
const [count, organization] = await Promise.all([
prisma.organization.count(),
prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, slug: true, name: true, status: true },
}),
]);
if (count !== 1) {
throw new Error(`Silo database must contain exactly one Organization; found ${count}`);
}
if (organization === null) {
throw new Error(
`Silo Organization mismatch: configured ${organizationId} is not the sole database Organization`,
);
}
if (organization.status !== "ACTIVE") {
throw new Error(`Silo Organization ${organizationId} is ${organization.status}`);
}
return { id: organization.id, slug: organization.slug, name: organization.name };
}
+25
View File
@@ -0,0 +1,25 @@
export class SiloFixedWindowRateLimiter {
private windowStartedAt: number;
private used = 0;
constructor(readonly limit: number, readonly windowMs: number, now = Date.now()) {
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("rate limit must be a positive integer");
if (!Number.isSafeInteger(windowMs) || windowMs <= 0) throw new Error("rate window must be a positive integer");
this.windowStartedAt = now;
}
consume(now = Date.now()): { readonly allowed: true } | { readonly allowed: false; readonly retryAfterSeconds: number } {
if (now >= this.windowStartedAt + this.windowMs) {
this.windowStartedAt = now;
this.used = 0;
}
if (this.used >= this.limit) {
return {
allowed: false,
retryAfterSeconds: Math.max(1, Math.ceil((this.windowStartedAt + this.windowMs - now) / 1000)),
};
}
this.used += 1;
return { allowed: true };
}
}
+40 -6
View File
@@ -25,6 +25,7 @@ export interface FeishuConfig {
export interface FeishuRuntime { export interface FeishuRuntime {
readonly client: lark.Client; readonly client: lark.Client;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
readonly isListenerReady?: () => boolean;
} }
export interface OutboundPayload { export interface OutboundPayload {
@@ -494,6 +495,7 @@ export async function downloadMessageFile(
workspaceDir: string, workspaceDir: string,
workspaceRelativePath: string, workspaceRelativePath: string,
resourceType: "image" | "file", resourceType: "image" | "file",
maxBytes?: number,
): Promise<string> { ): Promise<string> {
try { try {
return await withRetry(async () => { return await withRetry(async () => {
@@ -509,6 +511,7 @@ export async function downloadMessageFile(
workspaceDir, workspaceDir,
workspaceRelativePath, workspaceRelativePath,
source, source,
maxBytes,
); );
} catch (error) { } catch (error) {
source.destroy(); source.destroy();
@@ -911,20 +914,41 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
}); });
} }
export function startFeishuListenerWithClient( export async function startFeishuListenerWithClient(
config: FeishuConfig, config: FeishuConfig,
client: lark.Client, client: lark.Client,
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>, onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime { onTerminalError?: (error: Error) => void,
): Promise<FeishuRuntime> {
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
let resolveReady!: () => void;
let rejectReady!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const wsClient = new lark.WSClient({ const wsClient = new lark.WSClient({
appId: config.appId, appId: config.appId,
appSecret: config.appSecret, appSecret: config.appSecret,
domain: lark.Domain.Feishu, domain: lark.Domain.Feishu,
loggerLevel: lark.LoggerLevel.info, loggerLevel: lark.LoggerLevel.info,
handshakeTimeoutMs: 10_000,
onReady: () => {
state = "READY";
resolveReady();
logger.info("Feishu listener ready");
},
onError: (error) => {
const wasStarting = state === "STARTING";
state = "FAILED";
logger.error({ err: error }, "Feishu listener terminal failure");
if (wasStarting) rejectReady(error);
else onTerminalError?.(error);
},
}); });
const rt: FeishuRuntime = { client, logger }; const rt: FeishuRuntime = { client, logger, isListenerReady: () => state === "READY" };
const handlers: Record<string, (data: unknown) => Promise<void>> = { const handlers: Record<string, (data: unknown) => Promise<void>> = {
"im.message.receive_v1": async (data) => { "im.message.receive_v1": async (data) => {
try { await onMessage(data as MessageReceiveEvent, rt); } try { await onMessage(data as MessageReceiveEvent, rt); }
@@ -938,15 +962,25 @@ export function startFeishuListenerWithClient(
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); }); .catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
}; };
} }
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) }); await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
let timeout: ReturnType<typeof setTimeout> | undefined;
const startupTimeout = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("Feishu listener did not become ready within 15 seconds")), 15_000);
timeout.unref();
});
try {
await Promise.race([ready, startupTimeout]);
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
return rt; return rt;
} }
export function startFeishuListener( export async function startFeishuListener(
config: FeishuConfig, config: FeishuConfig,
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>, onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime { ): Promise<FeishuRuntime> {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction); return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
} }
+207
View File
@@ -0,0 +1,207 @@
import { createHash } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export type FeishuPrincipalKind = "USER" | "CHAT" | "DEPARTMENT" | "USER_GROUP" | "EVENT" | "CALLBACK";
export interface ScopedFeishuIdentity {
readonly identityId: string;
readonly connectionId: string;
readonly organizationId: string;
readonly userId: string;
readonly openId: string;
readonly unionId: string | null;
readonly principalId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
}
export function scopedFeishuPrincipalId(
kind: FeishuPrincipalKind,
connectionId: string,
providerLocalId: string,
): string {
const normalizedConnectionId = nonEmpty(connectionId, "Feishu connectionId");
const normalizedProviderLocalId = nonEmpty(providerLocalId, `Feishu ${kind} identifier`);
const digest = createHash("sha256")
.update("cph-feishu-principal\0")
.update(kind, "utf8")
.update("\0")
.update(normalizedConnectionId, "utf8")
.update("\0")
.update(normalizedProviderLocalId, "utf8")
.digest("base64url");
return `feishu:${kind.toLowerCase()}:${normalizedConnectionId}:${digest}`;
}
export async function upsertScopedFeishuIdentity(
prisma: PrismaClient,
input: {
readonly connectionId: string;
readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string;
readonly avatarUrl?: string | undefined;
},
): Promise<ScopedFeishuIdentity> {
return prisma.$transaction((tx) => upsertScopedFeishuIdentityInTransaction(tx, input));
}
export async function upsertScopedFeishuIdentityInTransaction(
prisma: Prisma.TransactionClient,
input: {
readonly connectionId: string;
readonly expectedOrganizationId?: string | undefined;
readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string;
readonly avatarUrl?: string | undefined;
},
): Promise<ScopedFeishuIdentity> {
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
const openId = nonEmpty(input.openId, "Feishu openId");
const displayName = nonEmpty(input.displayName, "Feishu displayName");
const unionId = optionalNonEmpty(input.unionId, "Feishu unionId");
const avatarUrl = optionalNonEmpty(input.avatarUrl, "Feishu avatarUrl");
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
const userId = deterministicFeishuUserId(connectionId, openId);
const connection = await lockActiveConnection(prisma, connectionId);
if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch");
}
const identity = await prisma.feishuUserIdentity.upsert({
where: { connectionId_openId: { connectionId, openId } },
update: {
...(unionId !== undefined ? { unionId } : {}),
user: {
update: {
displayName,
...(avatarUrl !== undefined ? { avatarUrl } : {}),
},
},
},
create: {
openId,
...(unionId !== undefined ? { unionId } : {}),
connection: { connect: { id: connectionId } },
user: {
connectOrCreate: {
where: { id: userId },
create: {
id: userId,
feishuOpenId: principalId,
displayName,
...(avatarUrl !== undefined ? { avatarUrl } : {}),
},
},
},
},
include: { user: true },
});
return scopedIdentity(connection.organizationId, identity);
}
export async function resolveScopedFeishuIdentity(
prisma: PrismaClient,
input: {
readonly connectionId: string;
readonly openId: string;
readonly expectedOrganizationId?: string | undefined;
},
): Promise<ScopedFeishuIdentity | null> {
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
const openId = nonEmpty(input.openId, "Feishu openId");
return prisma.$transaction(async (tx) => {
const connection = await lockActiveConnection(tx, connectionId);
if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch");
}
const identity = await tx.feishuUserIdentity.findUnique({
where: { connectionId_openId: { connectionId, openId } },
include: { user: true },
});
return identity === null ? null : scopedIdentity(connection.organizationId, identity);
});
}
async function lockActiveConnection(
prisma: Prisma.TransactionClient,
connectionId: string,
): Promise<{ readonly organizationId: string }> {
const scope = await prisma.organizationFeishuApplicationConnection.findUnique({
where: { id: connectionId },
select: { organizationId: true },
});
if (scope === null) throw new Error("active Feishu Application Connection not found");
await lockActiveOrganization(prisma, scope.organizationId);
await prisma.$queryRaw`
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
WHERE "id" = ${connectionId} FOR SHARE
`;
const connection = await prisma.organizationFeishuApplicationConnection.findUnique({
where: { id: connectionId },
select: {
organizationId: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
},
});
if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null ||
connection.organizationId !== scope.organizationId ||
connection.activeSecretVersion.connectionId !== connectionId ||
connection.activeSecretVersion.retiredAt !== null) {
throw new Error("active Feishu Application Connection not found");
}
return { organizationId: connection.organizationId };
}
function scopedIdentity(
organizationId: string,
identity: {
readonly id: string;
readonly connectionId: string;
readonly userId: string;
readonly openId: string;
readonly unionId: string | null;
readonly user: {
readonly displayName: string;
readonly avatarUrl: string | null;
};
},
): ScopedFeishuIdentity {
return {
identityId: identity.id,
connectionId: identity.connectionId,
organizationId,
userId: identity.userId,
openId: identity.openId,
unionId: identity.unionId,
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, identity.openId),
displayName: identity.user.displayName,
avatarUrl: identity.user.avatarUrl,
};
}
export function deterministicFeishuUserId(connectionId: string, openId: string): string {
const digest = createHash("sha256")
.update("cph-feishu-user\0")
.update(connectionId, "utf8")
.update("\0")
.update(openId, "utf8")
.digest("base64url");
return `feishu_${digest}`;
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
return normalized;
}
function optionalNonEmpty(value: string | undefined, label: string): string | undefined {
if (value === undefined) return undefined;
return nonEmpty(value, label);
}
+5
View File
@@ -37,8 +37,12 @@ export async function stageMessageResources(
messageId: string, messageId: string,
requests: readonly MessageResourceStageRequest[], requests: readonly MessageResourceStageRequest[],
workspaceRoot: string, workspaceRoot: string,
limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number },
): Promise<StagedMessageResourceBatch | null> { ): Promise<StagedMessageResourceBatch | null> {
if (requests.length === 0) return null; if (requests.length === 0) return null;
if (limits !== undefined && requests.length > limits.maxFiles) {
throw new Error(`Feishu message has ${requests.length} resources; limit is ${limits.maxFiles}`);
}
const stagingBase = await requirePrivateStagingBase(workspaceRoot); const stagingBase = await requirePrivateStagingBase(workspaceRoot);
const stagingRoot = join(stagingBase, randomUUID()); const stagingRoot = join(stagingBase, randomUUID());
const resources: StagedMessageResource[] = []; const resources: StagedMessageResource[] = [];
@@ -53,6 +57,7 @@ export async function stageMessageResources(
stagingRoot, stagingRoot,
`resource-${index}`, `resource-${index}`,
request.resourceType, request.resourceType,
limits?.maxBytesPerFile,
); );
resources.push({ resources.push({
resourceType: request.resourceType, resourceType: request.resourceType,
+132 -11
View File
@@ -68,6 +68,7 @@ import {
type OnboardingProjectOption, type OnboardingProjectOption,
type ProjectOnboardingActionValue, type ProjectOnboardingActionValue,
} from "./projectOnboardingCard.js"; } from "./projectOnboardingCard.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
export { ApprovalManager } from "./approval.js"; export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -84,6 +85,15 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined; readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot: string; readonly projectWorkspaceRoot: string;
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
readonly rejectWhenBusy?: boolean | undefined;
readonly resourceLimits?: {
readonly maxFilesPerMessage: number;
readonly maxBytesPerFile: number;
} | undefined;
readonly allowLegacyFeishuIdentity?: boolean | undefined;
/** Alpha Silo aggregate ingress ceiling across message and card events. */
readonly maxFeishuEventsPerMinute?: number | undefined;
} }
interface TriggerActor { interface TriggerActor {
@@ -102,6 +112,13 @@ interface TriggerRunContext {
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
class InstanceCapacityExhaustedError extends Error {
constructor(readonly limit: number) {
super(`Silo Agent capacity exhausted (${limit} active run(s))`);
this.name = "InstanceCapacityExhaustedError";
}
}
export interface TriggerHandler { export interface TriggerHandler {
(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>; (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>; readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
@@ -120,6 +137,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const runAgent = deps.runAgent ?? defaultRunAgent; const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager(); const approvalManager = new ApprovalManager();
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue; const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
? undefined
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
const slashCommands = createSlashCommandRegistry({ const slashCommands = createSlashCommandRegistry({
prisma: deps.prisma, prisma: deps.prisma,
settings: deps.settings, settings: deps.settings,
@@ -162,6 +182,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// ADR-0002: if locked by another run, enqueue the trigger for this project. // ADR-0002: if locked by another run, enqueue the trigger for this project.
const existing = await currentLockRunId(deps.prisma, projectId); const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) { if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked"; if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt); return enqueueLockedTrigger(context, cleanPrompt);
} }
@@ -224,7 +248,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
senderOpenId, senderOpenId,
}); });
const senderMetadata = await senderAuditMetadata(rt, senderOpenId); const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot); const stagedResources = await stageTriggerMessageResources(
rt,
msg,
projectWorkspaceRoot,
deps.resourceLimits,
);
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir); const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
@@ -239,6 +268,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
try { try {
admission = await deps.prisma.$transaction(async (tx) => { admission = await deps.prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, roleDecision.organizationId); await lockActiveOrganization(tx, roleDecision.organizationId);
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('cph-alpha-silo-agent-capacity'))`;
const activeRuns = await tx.agentRun.count({ where: { status: "ACTIVE" } });
if (activeRuns >= runPolicy.maxConcurrentRuns) {
throw new InstanceCapacityExhaustedError(runPolicy.maxConcurrentRuns);
}
// ADR-0017: provider+role+model-bound session. Reuse if one exists for // ADR-0017: provider+role+model-bound session. Reuse if one exists for
// this tuple; otherwise create. Role remains part of the key. // this tuple; otherwise create. Role remains part of the key.
@@ -299,9 +333,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions); await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
return "skipped"; return "skipped";
} }
if (error instanceof InstanceCapacityExhaustedError) {
deps.logger.info(
{ projectId, maxConcurrentRuns: error.limit },
"feishu trigger: Silo Agent capacity exhausted",
);
await sendText(rt, chatId, "当前组织处理任务已满,请稍后重试。", sendOptions);
return "rejected";
}
if (!isPrismaUniqueConstraintError(error)) throw error; if (!isPrismaUniqueConstraintError(error)) throw error;
const current = await currentLockRunId(deps.prisma, projectId); const current = await currentLockRunId(deps.prisma, projectId);
if (current === null) throw error; if (current === null) throw error;
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked"; if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt); return enqueueLockedTrigger(context, cleanPrompt);
} }
@@ -409,6 +455,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Abort handle for this run; the interrupt card action calls abort() to // Abort handle for this run; the interrupt card action calls abort() to
// stop the SDK query. Cleared in the finally below. // stop the SDK query. Cleared in the finally below.
const abortController = new AbortController(); const abortController = new AbortController();
let wallTimeExceeded = false;
const wallTimeTimer = setTimeout(() => {
wallTimeExceeded = true;
abortController.abort("run_wall_time_exceeded");
}, runPolicy.maxRunSeconds * 1000);
wallTimeTimer.unref();
activeRuns.set(run.id, abortController); activeRuns.set(run.id, abortController);
const agentExecution = (async () => { const agentExecution = (async () => {
const providerLease = await provider.openAgentLease({ runId: run.id }); const providerLease = await provider.openAgentLease({ runId: run.id });
@@ -477,7 +529,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
})(); })();
agentExecution agentExecution
.then(async (result) => { .then(async (result) => {
const interrupted = result.status === "interrupted"; const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const finalText = const finalText =
result.text !== "" result.text !== ""
? result.text ? result.text
@@ -495,7 +547,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
data: { data: {
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED", status: wallTimeExceeded
? "TIMED_OUT"
: interrupted
? "CANCELED"
: result.status === "completed"
? "COMPLETED"
: result.status === "length"
? "TIMED_OUT"
: "FAILED",
inputTokens: result.usage.inputTokens, inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens, outputTokens: result.usage.outputTokens,
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}), ...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
@@ -528,6 +588,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } }); await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
}) })
.finally(async () => { .finally(async () => {
clearTimeout(wallTimeTimer);
try { try {
await releaseLock(deps.prisma, run.id); await releaseLock(deps.prisma, run.id);
} catch (e) { } catch (e) {
@@ -621,6 +682,18 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => { const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
const rateDecision = feishuEventLimiter?.consume();
if (rateDecision !== undefined && !rateDecision.allowed) {
deps.logger.warn(
{
retryAfterSeconds: rateDecision.retryAfterSeconds,
chatId: nonEmpty(event.context?.open_chat_id),
operatorOpenId: nonEmpty(event.operator.open_id),
},
"feishu trigger: Silo event rate exceeded; card action rejected",
);
return;
}
// Approval buttons (file-delivery / explicit approval cards). // Approval buttons (file-delivery / explicit approval cards).
const approvalAction = approvalActionFromValue(event.action.value); const approvalAction = approvalActionFromValue(event.action.value);
if (approvalAction !== null) { if (approvalAction !== null) {
@@ -803,6 +876,28 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}); });
} }
const rateDecision = feishuEventLimiter?.consume();
if (rateDecision !== undefined && !rateDecision.allowed) {
deps.logger.warn(
{
retryAfterSeconds: rateDecision.retryAfterSeconds,
chatId: msg.chat_id,
messageId: msg.message_id,
senderOpenId: event.sender.sender_id.open_id,
},
"feishu trigger: Silo event rate exceeded; message rejected",
);
if (msg.chat_id !== "") {
await sendText(
rt,
msg.chat_id,
`当前服务请求过多,请约 ${rateDecision.retryAfterSeconds} 秒后重试。`,
sendOptionsForTriggerMessage(msg),
);
}
return;
}
// Only react to supported inbound message types in group chats. // Only react to supported inbound message types in group chats.
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return; if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
const chatId = msg.chat_id; const chatId = msg.chat_id;
@@ -913,6 +1008,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (msg.message_type === "text") { if (msg.message_type === "text") {
const existing = await currentLockRunId(deps.prisma, projectId); const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) { if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptionsForTriggerMessage(msg));
return;
}
await enqueueLockedTrigger(runContext, cleanPrompt); await enqueueLockedTrigger(runContext, cleanPrompt);
return; return;
} }
@@ -975,10 +1074,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
| { readonly status: "error"; readonly message: string } | { readonly status: "error"; readonly message: string }
> { > {
const user = await deps.prisma.user.findUnique({ const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: { feishuOpenId }, where: {
openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
},
select: { select: {
organizationMemberships: { user: { select: { organizationMemberships: {
where: { where: {
revokedAt: null, revokedAt: null,
organization: { status: "ACTIVE" }, organization: { status: "ACTIVE" },
@@ -988,12 +1090,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
organization: { select: { id: true, name: true } }, organization: { select: { id: true, name: true } },
}, },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}, } } },
}, },
}); });
if (user === null) { const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" }; ? await deps.prisma.user.findUnique({
} where: { feishuOpenId },
select: {
organizationMemberships: {
where: { revokedAt: null, organization: { status: "ACTIVE" } },
select: { role: true, organization: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null);
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
if (user.organizationMemberships.length === 0) { if (user.organizationMemberships.length === 0) {
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" }; return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
} }
@@ -1378,6 +1490,7 @@ async function stageTriggerMessageResources(
rt: FeishuRuntime, rt: FeishuRuntime,
msg: MessageReceiveEvent["message"], msg: MessageReceiveEvent["message"],
workspaceRoot: string, workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"],
): Promise<StagedMessageResourceBatch | null> { ): Promise<StagedMessageResourceBatch | null> {
const requests: MessageResourceStageRequest[] = []; const requests: MessageResourceStageRequest[] = [];
if (msg.message_type === "post") { if (msg.message_type === "post") {
@@ -1407,7 +1520,15 @@ async function stageTriggerMessageResources(
}); });
} }
} }
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot); return stageMessageResources(
rt,
msg.message_id,
requests,
workspaceRoot,
limits === undefined
? undefined
: { maxFiles: limits.maxFilesPerMessage, maxBytesPerFile: limits.maxBytesPerFile },
);
} }
function appendStagedResourcePaths( function appendStagedResourcePaths(
+96 -13
View File
@@ -10,7 +10,10 @@ import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js"; import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
import { openProviderProxyLease } from "./connections/providerProxy.js"; import { openProviderProxyLease } from "./connections/providerProxy.js";
import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js"; import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js";
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
import { readServerBinding } from "./settings/server.js"; import { readServerBinding } from "./settings/server.js";
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -30,7 +33,23 @@ function booleanEnv(name: string, fallback: boolean): boolean {
export async function startHub(): Promise<void> { export async function startHub(): Promise<void> {
requireEnv("DATABASE_URL"); requireEnv("DATABASE_URL");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT"); const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const app = Fastify({ logger: true }); const httpBodyLimit = positiveIntegerEnv("HUB_HTTP_BODY_LIMIT_BYTES");
const maxFilesPerMessage = positiveIntegerEnv("HUB_MAX_FILES_PER_MESSAGE");
const maxBytesPerFile = positiveIntegerEnv("HUB_MAX_FILE_BYTES");
const httpRequestsPerMinute = positiveIntegerEnv("HUB_HTTP_REQUESTS_PER_MINUTE");
const feishuEventsPerMinute = positiveIntegerEnv("HUB_FEISHU_EVENTS_PER_MINUTE");
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/healthz") return;
const decision = requestLimiter.consume();
if (!decision.allowed) {
await reply
.header("Retry-After", String(decision.retryAfterSeconds))
.status(429)
.send({ error: { code: "rate_limited", message: "Silo request rate exceeded" } });
}
});
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot); const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages"); app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
@@ -59,9 +78,39 @@ export async function startHub(): Promise<void> {
}), }),
); );
const feishuAppId = requireEnv("FEISHU_APP_ID"); const siloOrganizationId = readSiloOrganizationId();
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const siloOrganization = await requireSiloOrganization(prisma, siloOrganizationId);
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const activeProvider = await prisma.organizationProviderConnection.findUnique({
where: {
organizationId_providerId: {
organizationId: siloOrganization.id,
providerId: "openrouter",
},
},
select: {
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
id: true,
},
});
if (activeProvider?.status !== "ACTIVE" || activeProvider.activeSecretVersion === null ||
activeProvider.activeSecretVersion.connectionId !== activeProvider.id ||
activeProvider.activeSecretVersion.retiredAt !== null) {
throw new Error(`Silo Organization ${siloOrganization.id} has no ACTIVE openrouter Provider Connection`);
}
const feishuApplication = await resolveActiveFeishuApplication(
prisma,
secretEnvelope,
{ organizationId: siloOrganization.id },
);
app.log.info(
{
organizationId: siloOrganization.id,
organizationSlug: siloOrganization.slug,
feishuConnectionId: feishuApplication.connectionId,
},
"startup: proved Silo Organization and resolved Feishu Application",
);
const sessionSecret = requireEnv("HUB_SESSION_SECRET"); const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding(); const bind = readServerBinding();
@@ -74,24 +123,30 @@ export async function startHub(): Promise<void> {
}); });
app.log.info("startup: cleared stale locks + dead runs"); app.log.info("startup: cleared stale locks + dead runs");
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
app.get("/api/healthz", async (_request, reply) => {
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
return { ok: true, feishuReady, ts: Date.now() };
});
await registerAdminPlugin(app, { await registerAdminPlugin(app, {
prisma, prisma,
sessionSecret, sessionSecret,
publicBaseUrl, publicBaseUrl,
feishuAppId, feishuAppId: feishuApplication.appId,
feishuAppSecret, feishuAppSecret: feishuApplication.appSecret,
projectWorkspaceRoot, projectWorkspaceRoot,
secretEnvelope, secretEnvelope,
}); });
const address = await app.listen(bind);
app.log.info({ address }, "hub listening");
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) { if (feishuListenerEnabled) {
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; const feishuConfig = {
appId: feishuApplication.appId,
appSecret: feishuApplication.appSecret,
botOpenId: feishuApplication.botOpenId,
};
const larkClient = createLarkClient(feishuConfig); const larkClient = createLarkClient(feishuConfig);
const triggerQueuePurgeTimer = setInterval(() => { const triggerQueuePurgeTimer = setInterval(() => {
const removed = triggerQueue.purgeExpired(); const removed = triggerQueue.purgeExpired();
@@ -100,9 +155,37 @@ export async function startHub(): Promise<void> {
} }
}, 60_000); }, 60_000);
triggerQueuePurgeTimer.unref(); triggerQueuePurgeTimer.unref();
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot }); const trigger = makeTriggerHandler({
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction); prisma,
settings: runtimeSettings,
logger: app.log,
projectWorkspaceRoot,
rejectWhenBusy: true,
resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
allowLegacyFeishuIdentity: false,
maxFeishuEventsPerMinute: feishuEventsPerMinute,
});
feishuRuntime = await startFeishuListenerWithClient(
feishuConfig,
larkClient,
app.log,
trigger,
trigger.onCardAction,
() => {
process.exitCode = 1;
void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed"));
},
);
} else { } else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
} }
const address = await app.listen(bind);
app.log.info({ address }, "hub listening");
}
function positiveIntegerEnv(name: string): number {
const raw = requireEnv(name);
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
return value;
} }
+54 -24
View File
@@ -8,11 +8,13 @@
* 4. ADMIN cannot modify OWNER memberships. * 4. ADMIN cannot modify OWNER memberships.
*/ */
import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client"; import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client";
import { upsertScopedFeishuIdentityInTransaction } from "../feishu/identityNamespace.js";
import { lockActiveOrganization } from "./status.js"; import { lockActiveOrganization } from "./status.js";
export interface OrgMemberRow { export interface OrgMemberRow {
readonly userId: string; readonly userId: string;
readonly feishuOpenId: string; readonly feishuOpenId: string | null;
readonly identityStatus: "SCOPED" | "UNLINKED";
readonly displayName: string; readonly displayName: string;
readonly avatarUrl: string | null; readonly avatarUrl: string | null;
readonly role: OrganizationMemberRole; readonly role: OrganizationMemberRole;
@@ -29,14 +31,24 @@ export async function listOrgMembers(
role: true, role: true,
createdAt: true, createdAt: true,
user: { user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true }, select: {
id: true,
displayName: true,
avatarUrl: true,
feishuIdentities: {
where: { connection: { organizationId } },
select: { openId: true },
take: 1,
},
},
}, },
}, },
orderBy: [{ role: "asc" }, { createdAt: "asc" }], orderBy: [{ role: "asc" }, { createdAt: "asc" }],
}); });
return rows.map((row) => ({ return rows.map((row) => ({
userId: row.user.id, userId: row.user.id,
feishuOpenId: row.user.feishuOpenId, feishuOpenId: row.user.feishuIdentities[0]?.openId ?? null,
identityStatus: row.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED",
displayName: row.user.displayName, displayName: row.user.displayName,
avatarUrl: row.user.avatarUrl, avatarUrl: row.user.avatarUrl,
role: row.role, role: row.role,
@@ -57,39 +69,47 @@ export async function addOrgMember(
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId"); const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false); assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
const { user, membership } = await prisma.$transaction(async (tx) => { const { identity, membership } = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({ const connection = await tx.organizationFeishuApplicationConnection.findUnique({
where: { feishuOpenId: openId }, where: { organizationId: input.organizationId },
create: { select: {
feishuOpenId: openId, id: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
},
});
if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null ||
connection.activeSecretVersion.connectionId !== connection.id ||
connection.activeSecretVersion.retiredAt !== null) {
throw new Error("active Feishu Application Connection not found");
}
const identity = await upsertScopedFeishuIdentityInTransaction(tx, {
connectionId: connection.id,
expectedOrganizationId: input.organizationId,
openId,
displayName: input.displayName?.trim() || openId, displayName: input.displayName?.trim() || openId,
},
update: {
...(input.displayName !== undefined && input.displayName.trim() !== ""
? { displayName: input.displayName.trim() }
: {}),
},
}); });
const existing = await tx.organizationMembership.findFirst({ const existing = await tx.organizationMembership.findFirst({
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null }, where: { organizationId: input.organizationId, userId: identity.userId, revokedAt: null },
}); });
if (existing !== null) throw new Error(`user ${user.id} is already an active member`); if (existing !== null) throw new Error(`user ${identity.userId} is already an active member`);
const membership = await tx.organizationMembership.create({ const membership = await tx.organizationMembership.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
userId: user.id, userId: identity.userId,
role: input.role, role: input.role,
}, },
}); });
return { user, membership }; return { identity, membership };
}); });
return { return {
userId: user.id, userId: identity.userId,
feishuOpenId: user.feishuOpenId, feishuOpenId: identity.openId,
displayName: user.displayName, identityStatus: "SCOPED",
avatarUrl: user.avatarUrl, displayName: identity.displayName,
avatarUrl: identity.avatarUrl,
role: membership.role, role: membership.role,
createdAt: membership.createdAt.toISOString(), createdAt: membership.createdAt.toISOString(),
}; };
@@ -118,7 +138,16 @@ export async function setOrgMemberRole(
role: true, role: true,
createdAt: true, createdAt: true,
user: { user: {
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true }, select: {
id: true,
displayName: true,
avatarUrl: true,
feishuIdentities: {
where: { connection: { organizationId: input.organizationId } },
select: { openId: true },
take: 1,
},
},
}, },
}, },
}); });
@@ -139,7 +168,8 @@ export async function setOrgMemberRole(
return { return {
userId: membership.user.id, userId: membership.user.id,
feishuOpenId: membership.user.feishuOpenId, feishuOpenId: membership.user.feishuIdentities[0]?.openId ?? null,
identityStatus: membership.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED",
displayName: membership.user.displayName, displayName: membership.user.displayName,
avatarUrl: membership.user.avatarUrl, avatarUrl: membership.user.avatarUrl,
role: updated.role, role: updated.role,
+47 -12
View File
@@ -168,16 +168,30 @@ export async function listTeamMembers(
where: { teamId: input.teamId, revokedAt: null }, where: { teamId: input.teamId, revokedAt: null },
select: { select: {
createdAt: true, createdAt: true,
user: { select: { id: true, feishuOpenId: true, displayName: true } }, user: {
select: {
id: true,
feishuOpenId: true,
displayName: true,
feishuIdentities: {
where: { connection: { organizationId: input.organizationId } },
select: { openId: true },
take: 1,
},
},
},
}, },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
return rows.map((row) => ({ return rows.map((row) => {
const identity = row.user.feishuIdentities[0];
return {
userId: row.user.id, userId: row.user.id,
feishuOpenId: row.user.feishuOpenId, feishuOpenId: identity?.openId ?? row.user.feishuOpenId,
displayName: row.user.displayName, displayName: row.user.displayName,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
})); };
});
} }
export async function addTeamMember( export async function addTeamMember(
@@ -244,23 +258,44 @@ export async function revokeTeamMember(
async function resolveUser( async function resolveUser(
prisma: PrismaClient | Prisma.TransactionClient, prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined }, input: {
readonly organizationId: string;
readonly userId?: string | undefined;
readonly feishuOpenId?: string | undefined;
},
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> { ): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
if (input.userId !== undefined && input.userId !== "") { if (input.userId !== undefined && input.userId !== "") {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: input.userId }, where: { id: input.userId },
select: { id: true, feishuOpenId: true, displayName: true }, select: {
id: true,
feishuOpenId: true,
displayName: true,
feishuIdentities: {
where: { connection: { organizationId: input.organizationId } },
select: { openId: true },
take: 1,
},
},
}); });
if (user === null) throw new Error(`user not found: ${input.userId}`); if (user === null) throw new Error(`user not found: ${input.userId}`);
return user; const identity = user.feishuIdentities[0];
return {
id: user.id,
feishuOpenId: identity?.openId ?? user.feishuOpenId,
displayName: user.displayName,
};
} }
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") { if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
const user = await prisma.user.findUnique({ const identity = await prisma.feishuUserIdentity.findFirst({
where: { feishuOpenId: input.feishuOpenId }, where: {
select: { id: true, feishuOpenId: true, displayName: true }, openId: input.feishuOpenId,
connection: { organizationId: input.organizationId, status: "ACTIVE" },
},
select: { openId: true, user: { select: { id: true, displayName: true } } },
}); });
if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`); if (identity === null) throw new Error(`user not found in organization: ${input.feishuOpenId}`);
return user; return { id: identity.user.id, feishuOpenId: identity.openId, displayName: identity.user.displayName };
} }
throw new Error("userId or feishuOpenId is required"); throw new Error("userId or feishuOpenId is required");
} }
+49 -7
View File
@@ -1,4 +1,5 @@
import type { PrismaClient, PrincipalType } from "@prisma/client"; import type { PrismaClient, PrincipalType } from "@prisma/client";
import { scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
export interface PrincipalRef { export interface PrincipalRef {
readonly type: PrincipalType; readonly type: PrincipalType;
@@ -40,19 +41,60 @@ export interface PrincipalResolver {
} }
export class PrismaPrincipalResolver implements PrincipalResolver { export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(private readonly prisma: PrismaClient) {} constructor(
private readonly prisma: PrismaClient,
private readonly allowLegacyIdentity = process.env["NODE_ENV"] !== "production",
) {}
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> { async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector(); const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user"); const scopedIdentity = await this.prisma.feishuUserIdentity.findFirst({
if (actor.chatId !== undefined && actor.chatId !== "") { where: {
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat"); openId: actor.feishuOpenId,
} connection: {
organizationId: scope.organizationId,
const user = await this.prisma.user.findUnique({ status: "ACTIVE",
activeSecretVersion: { retiredAt: null },
},
},
select: { userId: true, connectionId: true },
});
// Explicit migration adapter for pre-ADR-0024 fixtures/data. Alpha Silo
// ingress always has an ACTIVE connection-scoped identity.
const user = scopedIdentity === null && this.allowLegacyIdentity
? await this.prisma.user.findUnique({
where: { feishuOpenId: actor.feishuOpenId }, where: { feishuOpenId: actor.feishuOpenId },
select: { id: true }, select: { id: true },
})
: scopedIdentity === null ? null : { id: scopedIdentity.userId };
const connection = await this.prisma.organizationFeishuApplicationConnection.findUnique({
where: { organizationId: scope.organizationId },
select: {
id: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
},
}); });
const activeConnectionId = scopedIdentity?.connectionId ?? (
connection?.status === "ACTIVE" && connection.activeSecretVersion !== null &&
connection.activeSecretVersion.connectionId === connection.id &&
connection.activeSecretVersion.retiredAt === null
? connection.id
: undefined
);
if (activeConnectionId === undefined && !this.allowLegacyIdentity) {
throw new Error(`active Feishu Application Connection not found for organization ${scope.organizationId}`);
}
const userPrincipalId = activeConnectionId === undefined
? actor.feishuOpenId
: scopedFeishuPrincipalId("USER", activeConnectionId, actor.feishuOpenId);
resolved.add({ type: "USER", id: userPrincipalId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
const chatPrincipalId = activeConnectionId === undefined
? actor.chatId
: scopedFeishuPrincipalId("CHAT", activeConnectionId, actor.chatId);
resolved.add({ type: "FEISHU_CHAT", id: chatPrincipalId }, "context-chat");
}
if (user !== null) { if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({ const memberships = await this.prisma.teamMembership.findMany({
+58 -11
View File
@@ -5,6 +5,7 @@ import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClie
import { createPermissionAuthorizer } from "./permission.js"; import { createPermissionAuthorizer } from "./permission.js";
import { writeAudit } from "./audit.js"; import { writeAudit } from "./audit.js";
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js"; import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
import { scopedFeishuPrincipalId } from "./feishu/identityNamespace.js";
export interface OrganizationProjectPolicy { export interface OrganizationProjectPolicy {
readonly organizationId: string; readonly organizationId: string;
@@ -140,7 +141,8 @@ export async function createProjectFromOrgAdmin(
return createManagedProject(prisma, { return createManagedProject(prisma, {
organizationId: input.organizationId, organizationId: input.organizationId,
actorUserId: actor.userId, actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId, actorPrincipalId: actor.principalId,
feishuConnectionId: actor.connectionId,
name, name,
workspaceRoot: input.workspaceRoot, workspaceRoot: input.workspaceRoot,
folderId: input.folderId, folderId: input.folderId,
@@ -170,7 +172,8 @@ export async function createProjectFromFeishuChat(
return createManagedProject(prisma, { return createManagedProject(prisma, {
organizationId: input.organizationId, organizationId: input.organizationId,
actorUserId: actor.userId, actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId, actorPrincipalId: actor.principalId,
feishuConnectionId: actor.connectionId,
name, name,
workspaceRoot: input.workspaceRoot, workspaceRoot: input.workspaceRoot,
folderId: input.folderId, folderId: input.folderId,
@@ -212,7 +215,7 @@ export async function bindFeishuChatToProject(
await replaceProjectGrant(tx, { await replaceProjectGrant(tx, {
projectId: project.id, projectId: project.id,
principalType: "FEISHU_CHAT", principalType: "FEISHU_CHAT",
principalId: chatId, principalId: scopedChatPrincipalId(actor.connectionId, chatId),
role: "EDIT", role: "EDIT",
createdByUserId: actor.userId, createdByUserId: actor.userId,
}); });
@@ -262,7 +265,7 @@ export async function archiveFeishuChatBinding(
resourceType: "PROJECT", resourceType: "PROJECT",
resourceId: binding.projectId, resourceId: binding.projectId,
principalType: "FEISHU_CHAT", principalType: "FEISHU_CHAT",
principalId: binding.chatId, principalId: scopedChatPrincipalId(actor.connectionId, binding.chatId),
revokedAt: null, revokedAt: null,
}, },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
@@ -282,7 +285,8 @@ async function createManagedProject(
input: { input: {
readonly organizationId: string; readonly organizationId: string;
readonly actorUserId: string; readonly actorUserId: string;
readonly actorFeishuOpenId: string; readonly actorPrincipalId: string;
readonly feishuConnectionId: string | undefined;
readonly name: string; readonly name: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly folderId: string | undefined; readonly folderId: string | undefined;
@@ -332,7 +336,7 @@ async function createManagedProject(
await replaceProjectGrant(tx, { await replaceProjectGrant(tx, {
projectId: created.id, projectId: created.id,
principalType: "USER", principalType: "USER",
principalId: input.actorFeishuOpenId, principalId: input.actorPrincipalId,
role: "MANAGE", role: "MANAGE",
createdByUserId: input.actorUserId, createdByUserId: input.actorUserId,
}); });
@@ -343,7 +347,7 @@ async function createManagedProject(
await replaceProjectGrant(tx, { await replaceProjectGrant(tx, {
projectId: created.id, projectId: created.id,
principalType: "FEISHU_CHAT", principalType: "FEISHU_CHAT",
principalId: input.chatId, principalId: scopedChatPrincipalId(input.feishuConnectionId, input.chatId),
role: "EDIT", role: "EDIT",
createdByUserId: input.actorUserId, createdByUserId: input.actorUserId,
}); });
@@ -385,7 +389,13 @@ async function requireProjectManager(
projectId: string, projectId: string,
organizationId: string, organizationId: string,
actorFeishuOpenId: string, actorFeishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> { ): Promise<{
readonly userId: string;
readonly role: OrganizationMemberRole;
readonly principalId: string;
readonly connectionId: string | undefined;
readonly via: "org-admin" | "project-manage";
}> {
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId); const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
if (isOrgAdminRole(actor.role)) { if (isOrgAdminRole(actor.role)) {
return { ...actor, via: "org-admin" }; return { ...actor, via: "org-admin" };
@@ -405,9 +415,42 @@ async function requireActiveOrgMember(
prisma: PrismaClient, prisma: PrismaClient,
organizationId: string, organizationId: string,
feishuOpenId: string, feishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> { ): Promise<{
readonly userId: string;
readonly role: OrganizationMemberRole;
readonly principalId: string;
readonly connectionId: string | undefined;
}> {
await requireActiveOrganization(prisma, organizationId); await requireActiveOrganization(prisma, organizationId);
const user = await prisma.user.findUnique({ const identity = await prisma.feishuUserIdentity.findFirst({
where: { openId: feishuOpenId, connection: { organizationId, status: "ACTIVE" } },
select: {
connectionId: true,
user: { select: {
id: true,
organizationMemberships: {
where: { organizationId, revokedAt: null },
select: { role: true },
take: 1,
},
} },
},
});
if (identity !== null) {
const membership = identity.user.organizationMemberships[0];
if (membership === undefined) {
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
}
return {
userId: identity.user.id,
role: membership.role,
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, feishuOpenId),
connectionId: identity.connectionId,
};
}
// Explicit compatibility path for pre-ADR-0024 rows while the Silo branch
// completes migration. New bootstrap/member writes always create identities.
const user = process.env["NODE_ENV"] === "production" ? null : await prisma.user.findUnique({
where: { feishuOpenId }, where: { feishuOpenId },
select: { select: {
id: true, id: true,
@@ -425,7 +468,11 @@ async function requireActiveOrgMember(
if (membership === undefined) { if (membership === undefined) {
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`); throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
} }
return { userId: user.id, role: membership.role }; return { userId: user.id, role: membership.role, principalId: feishuOpenId, connectionId: undefined };
}
function scopedChatPrincipalId(connectionId: string | undefined, chatId: string): string {
return connectionId === undefined ? chatId : scopedFeishuPrincipalId("CHAT", connectionId, chatId);
} }
async function ensureOrganizationProjectSettingsTx( async function ensureOrganizationProjectSettingsTx(
+13 -1
View File
@@ -7,7 +7,7 @@ export class WorkspaceFileBoundaryError extends Error {
constructor( constructor(
message: string, message: string,
readonly requestedPath: string, readonly requestedPath: string,
readonly reason: "not_found" | "boundary" | "io" = "boundary", readonly reason: "not_found" | "boundary" | "io" | "limit" = "boundary",
options?: ErrorOptions, options?: ErrorOptions,
) { ) {
super(message, options); super(message, options);
@@ -77,12 +77,14 @@ export async function writeNewWorkspaceFileNoFollow(
workspaceDir: string, workspaceDir: string,
requestedPath: string, requestedPath: string,
source: Readable, source: Readable,
maxBytes?: number,
): Promise<string> { ): Promise<string> {
return (await writeNewWorkspaceFileNoFollowTracked( return (await writeNewWorkspaceFileNoFollowTracked(
workspaceRoot, workspaceRoot,
workspaceDir, workspaceDir,
requestedPath, requestedPath,
source, source,
maxBytes,
)).path; )).path;
} }
@@ -92,6 +94,7 @@ export async function writeNewWorkspaceFileNoFollowTracked(
workspaceDir: string, workspaceDir: string,
requestedPath: string, requestedPath: string,
source: Readable, source: Readable,
maxBytes?: number,
): Promise<WorkspaceFileWriteResult> { ): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath); const components = fileComponents(workspace, requestedPath);
@@ -114,8 +117,17 @@ export async function writeNewWorkspaceFileNoFollowTracked(
const metadata = await file.stat(); const metadata = await file.stat();
device = metadata.dev; device = metadata.dev;
inode = metadata.ino; inode = metadata.ino;
let bytesWritten = 0;
for await (const chunk of source) { for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
bytesWritten += buffer.length;
if (maxBytes !== undefined && bytesWritten > maxBytes) {
throw new WorkspaceFileBoundaryError(
`inbound file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
let offset = 0; let offset = 0;
while (offset < buffer.length) { while (offset < buffer.length) {
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null); const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
+1 -1
View File
@@ -10,7 +10,7 @@
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`. * Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
* *
* Env (see .env.example): * Env (see .env.example):
* DATABASE_URL, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT, * DATABASE_URL, HUB_SILO_ORGANIZATION_ID, PORT,
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL. * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL.
* Provider credentials are org-scoped encrypted records; production receives * Provider credentials are org-scoped encrypted records; production receives
* the local master-key keyring only through systemd CREDENTIALS_DIRECTORY. * the local master-key keyring only through systemd CREDENTIALS_DIRECTORY.
+14
View File
@@ -12,6 +12,8 @@ type EnvSource = Env | (() => Env);
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5"; const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5"; const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25; const DEFAULT_AGENT_MAX_TURNS = 25;
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
export interface ProviderRuntimeSettings { export interface ProviderRuntimeSettings {
readonly id: string; readonly id: string;
@@ -43,6 +45,8 @@ export interface RunPolicyInput {
export interface RunPolicy { export interface RunPolicy {
readonly maxTurns: number; readonly maxTurns: number;
readonly maxConcurrentRuns: number;
readonly maxRunSeconds: number;
} }
export interface RuntimeSettings { export interface RuntimeSettings {
@@ -75,6 +79,16 @@ export class EnvRuntimeSettings implements RuntimeSettings {
void input; void input;
return { return {
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS), maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
maxConcurrentRuns: positiveIntegerEnv(
this.envSource(),
"HUB_AGENT_MAX_CONCURRENT_RUNS",
DEFAULT_AGENT_MAX_CONCURRENT_RUNS,
),
maxRunSeconds: positiveIntegerEnv(
this.envSource(),
"HUB_AGENT_MAX_RUN_SECONDS",
DEFAULT_AGENT_MAX_RUN_SECONDS,
),
}; };
} }
} }
+119 -2
View File
@@ -9,12 +9,14 @@ import {
sessionCookieHeader, sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js"; } from "../../src/admin/routes/authRoutes.js";
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js"; import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../src/feishu/identityNamespace.js";
const SESSION_SECRET = "integration-test-session-secret"; const SESSION_SECRET = "integration-test-session-secret";
const PUBLIC_BASE = "http://127.0.0.1:8788"; const PUBLIC_BASE = "http://127.0.0.1:8788";
async function buildApp(fetchImpl?: typeof fetch) { async function buildApp(fetchImpl?: typeof fetch, allowLegacyFeishuOAuth = true) {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
await registerAdminPlugin(app, { await registerAdminPlugin(app, {
prisma, prisma,
@@ -24,6 +26,7 @@ async function buildApp(fetchImpl?: typeof fetch) {
feishuAppSecret: "secret_test", feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-workspaces", projectWorkspaceRoot: "/tmp/cph-test-workspaces",
secretEnvelope: testSecretEnvelope, secretEnvelope: testSecretEnvelope,
allowLegacyFeishuOAuth,
cookieSecure: false, cookieSecure: false,
...(fetchImpl !== undefined ? { fetchImpl } : {}), ...(fetchImpl !== undefined ? { fetchImpl } : {}),
}); });
@@ -186,6 +189,25 @@ describe("admin auth + org API guards", () => {
} }
}); });
it("does not expose unscoped OAuth when the explicit compatibility switch is off", async () => {
const app = await buildApp(undefined, false);
try {
const start = await app.inject({ method: "GET", url: "/auth/feishu" });
expect(start.statusCode).toBe(404);
const nonce = "legacy-disabled";
const state = signOAuthState({ nonce, returnTo: "/admin" }, SESSION_SECRET);
const callback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`,
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toContain("oauth_failed");
} finally {
await app.close();
}
});
it("OAuth callback upserts user and sets session cookie", async () => { it("OAuth callback upserts user and sets session cookie", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
@@ -230,6 +252,94 @@ describe("admin auth + org API guards", () => {
} }
}); });
it("binds Organization OAuth state, identity, and session to the intended connection", async () => {
await seedUser("scoped-owner", "legacy_scoped_owner", "OWNER");
const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const connection = await connections.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "scoped-owner",
appId: "cli_customer_oauth",
appSecret: "customer-oauth-secret",
botOpenId: "ou_customer_bot",
});
const identity = await upsertScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_scoped_user",
displayName: "Invited User",
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "ADMIN" },
});
await seedTestOrganization("org_scoped_other", "scoped-other");
await prisma.organizationMembership.create({
data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" },
});
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/oauth/token")) {
expect(JSON.parse(String(init?.body))).toMatchObject({ client_id: "cli_customer_oauth" });
return Response.json({ code: 0, access_token: "scoped-user-token" });
}
if (url.includes("/user_info")) {
return Response.json({
code: 0,
data: {
open_id: "ou_scoped_user",
union_id: "on_scoped_union",
name: "Scoped User",
},
});
}
throw new Error(`unexpected ${url}`);
});
const app = await buildApp(fetchImpl as unknown as typeof fetch);
try {
const start = await app.inject({
method: "GET",
url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings",
});
expect(start.statusCode).toBe(302);
const authorize = new URL(String(start.headers.location));
expect(authorize.searchParams.get("client_id")).toBe("cli_customer_oauth");
const state = authorize.searchParams.get("state");
expect(state).not.toBeNull();
const nonceCookie = cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME);
const callback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
headers: { cookie: nonceCookie },
});
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("/admin/org/test-default/settings");
const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({
user: { id: identity.userId, displayName: "Scoped User" },
organizations: [expect.objectContaining({ slug: "test-default", role: "ADMIN" })],
});
expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
const crossOrganization = await app.inject({
method: "GET",
url: "/api/org/scoped-other",
headers: { cookie: sessionCookie },
});
expect(crossOrganization.statusCode).toBe(403);
expect(crossOrganization.json()).toMatchObject({ error: { code: "forbidden" } });
await expect(prisma.feishuUserIdentity.findUniqueOrThrow({
where: { id: identity.identityId },
select: { unionId: true },
})).resolves.toEqual({ unionId: "on_scoped_union" });
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
expect(revoked.statusCode).toBe(401);
} finally {
await app.close();
}
});
it("unknown org slug returns 404 for admin", async () => { it("unknown org slug returns 404 for admin", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN"); await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp(); const app = await buildApp();
@@ -249,3 +359,10 @@ describe("admin auth + org API guards", () => {
} }
}); });
}); });
function cookiePair(header: string | string[] | undefined, name: string): string {
const values = Array.isArray(header) ? header : [header ?? ""];
const cookie = values.find((value) => value.startsWith(`${name}=`));
if (cookie === undefined) throw new Error(`missing cookie: ${name}`);
return cookie.split(";", 1)[0]!;
}
@@ -11,6 +11,7 @@ import {
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
import { addOrgMember } from "../../src/org/members.js"; import { addOrgMember } from "../../src/org/members.js";
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js"; import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const SESSION_SECRET = "integration-test-session-secret"; const SESSION_SECRET = "integration-test-session-secret";
@@ -51,6 +52,14 @@ describe("admin members + teams API", () => {
it("adds members and enforces last-OWNER protection", async () => { it("adds members and enforces last-OWNER protection", async () => {
const token = await seedOwner(); const token = await seedOwner();
const connection = await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {})
.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "u-owner",
appId: "cli_members",
appSecret: "members-secret",
botOpenId: "ou_members_bot",
});
const app = await buildApp(); const app = await buildApp();
try { try {
const cookie = sessionCookieHeader(token); const cookie = sessionCookieHeader(token);
@@ -64,6 +73,14 @@ describe("admin members + teams API", () => {
expect(add.statusCode).toBe(201); expect(add.statusCode).toBe(201);
const teacher = add.json() as { userId: string; role: string }; const teacher = add.json() as { userId: string; role: string };
expect(teacher.role).toBe("ADMIN"); expect(teacher.role).toBe("ADMIN");
await expect(prisma.feishuUserIdentity.findUnique({
where: {
connectionId_openId: {
connectionId: connection.id,
openId: "ou_teacher",
},
},
})).resolves.not.toBeNull();
const list = await app.inject({ const list = await app.inject({
method: "GET", method: "GET",
@@ -71,7 +88,18 @@ describe("admin members + teams API", () => {
headers: { cookie }, headers: { cookie },
}); });
expect(list.statusCode).toBe(200); expect(list.statusCode).toBe(200);
expect((list.json() as { members: unknown[] }).members).toHaveLength(2); const members = (list.json() as {
members: Array<{ userId: string; feishuOpenId: string | null; identityStatus: string }>;
}).members;
expect(members).toHaveLength(2);
expect(members.find((member) => member.userId === "u-owner")).toMatchObject({
feishuOpenId: null,
identityStatus: "UNLINKED",
});
expect(members.find((member) => member.userId === teacher.userId)).toMatchObject({
feishuOpenId: "ou_teacher",
identityStatus: "SCOPED",
});
const refuse = await app.inject({ const refuse = await app.inject({
method: "POST", method: "POST",
@@ -18,7 +18,7 @@ import { FeishuApplicationConnectionService } from "../../src/connections/feishu
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", () => { describe("deployment preflight CLI", { timeout: 20_000 }, () => {
let root: string; let root: string;
let baseDir: string; let baseDir: string;
let hubDir: string; let hubDir: string;
@@ -224,15 +224,21 @@ describe("deployment preflight CLI", () => {
[ [
"NODE_ENV=production", "NODE_ENV=production",
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`, `DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
"FEISHU_APP_ID=cli_app_id", "HUB_SILO_ORGANIZATION_ID=org_test",
"FEISHU_APP_SECRET=feishu-app-secret", "HUB_SYSTEMD_UNIT=cph-hub-test.service",
"FEISHU_BOT_OPEN_ID=ou_bot",
`CPH_BIN=${cphBin}`, `CPH_BIN=${cphBin}`,
"HOST=127.0.0.1", "HOST=127.0.0.1",
"PORT=8788", "PORT=8788",
`HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`, `HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`,
"HUB_PUBLIC_BASE_URL=https://hub.example.com", "HUB_PUBLIC_BASE_URL=https://hub.example.com",
"HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes", "HUB_SESSION_SECRET=a-production-session-secret-with-32-bytes",
"HUB_AGENT_MAX_CONCURRENT_RUNS=1",
"HUB_AGENT_MAX_RUN_SECONDS=900",
"HUB_HTTP_BODY_LIMIT_BYTES=1048576",
"HUB_MAX_FILES_PER_MESSAGE=8",
"HUB_MAX_FILE_BYTES=26214400",
"HUB_HTTP_REQUESTS_PER_MINUTE=120",
"HUB_FEISHU_EVENTS_PER_MINUTE=120",
"", "",
].join("\n"), ].join("\n"),
{ mode: 0o600 }, { mode: 0o600 },
@@ -264,6 +270,8 @@ describe("deployment preflight CLI", () => {
join(persistentDir, "cache"), join(persistentDir, "cache"),
"--workspace-root", "--workspace-root",
options.workspaceRoot, options.workspaceRoot,
"--service-unit",
"cph-hub-test.service",
"--bwrap-bin", "--bwrap-bin",
bwrapBin, bwrapBin,
"--socat-bin", "--socat-bin",
@@ -0,0 +1,132 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
import {
scopedFeishuPrincipalId,
upsertScopedFeishuIdentity,
resolveScopedFeishuIdentity,
} from "../../src/feishu/identityNamespace.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
describe("Feishu connection identity namespace", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("keeps colliding provider-local user and chat ids isolated by connection", async () => {
const first = await seedConnection(DEFAULT_ORG_ID, "identity-admin-a", "cli_identity_a");
await seedTestOrganization("org_identity_other", "identity-other");
const second = await seedConnection("org_identity_other", "identity-admin-b", "cli_identity_b");
const [identityA, identityB] = await Promise.all([
upsertScopedFeishuIdentity(prisma, {
connectionId: first.id,
openId: "ou_collision",
displayName: "Teacher A",
}),
upsertScopedFeishuIdentity(prisma, {
connectionId: second.id,
openId: "ou_collision",
displayName: "Teacher B",
}),
]);
expect(identityA.userId).not.toBe(identityB.userId);
expect(identityA.principalId).not.toBe(identityB.principalId);
expect(scopedFeishuPrincipalId("CHAT", first.id, "oc_collision"))
.not.toBe(scopedFeishuPrincipalId("CHAT", second.id, "oc_collision"));
await expect(resolveScopedFeishuIdentity(prisma, {
connectionId: first.id,
openId: "ou_collision",
})).resolves.toMatchObject({ organizationId: DEFAULT_ORG_ID, userId: identityA.userId });
await expect(resolveScopedFeishuIdentity(prisma, {
connectionId: second.id,
openId: "ou_collision",
})).resolves.toMatchObject({ organizationId: "org_identity_other", userId: identityB.userId });
});
it("fails closed for an Organization mismatch or disabled connection", async () => {
const connection = await seedConnection(DEFAULT_ORG_ID, "identity-owner", "cli_identity");
await upsertScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_teacher",
displayName: "Teacher",
});
await expect(resolveScopedFeishuIdentity(prisma, {
connectionId: connection.id,
expectedOrganizationId: "org_wrong",
openId: "ou_teacher",
})).rejects.toThrow("scope mismatch");
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).disable({
organizationId: DEFAULT_ORG_ID,
actorUserId: "identity-owner",
});
await expect(resolveScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_teacher",
})).rejects.toThrow("active Feishu Application Connection not found");
});
it("serializes identity creation behind a concurrent connection disable", async () => {
const connection = await seedConnection(DEFAULT_ORG_ID, "identity-owner", "cli_race");
let release!: () => void;
const releasePromise = new Promise<void>((resolve) => { release = resolve; });
let locked!: () => void;
const lockedPromise = new Promise<void>((resolve) => { locked = resolve; });
const disabler = prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
WHERE "id" = ${connection.id} FOR UPDATE
`;
locked();
await releasePromise;
await tx.organizationFeishuApplicationConnection.update({
where: { id: connection.id },
data: { status: "DISABLED", disabledAt: new Date() },
});
}, { timeout: 10_000 });
await lockedPromise;
const identityAttempt = upsertScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_racing_user",
displayName: "Racing User",
});
const stateBeforeRelease = await Promise.race([
identityAttempt.then(() => "settled", () => "settled"),
new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 50)),
]);
expect(stateBeforeRelease).toBe("blocked");
release();
await disabler;
await expect(identityAttempt).rejects.toThrow("active Feishu Application Connection not found");
await expect(prisma.feishuUserIdentity.count({
where: { connectionId: connection.id, openId: "ou_racing_user" },
})).resolves.toBe(0);
});
});
async function seedConnection(
organizationId: string,
actorUserId: string,
appId: string,
): Promise<{ readonly id: string }> {
await prisma.user.create({
data: { id: actorUserId, feishuOpenId: `legacy_${actorUserId}`, displayName: actorUserId },
});
await prisma.organizationMembership.create({
data: { organizationId, userId: actorUserId, role: "OWNER" },
});
return new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {})
.rotateCustomerApplication({
organizationId,
actorUserId,
appId,
appSecret: `secret_${appId}`,
botOpenId: `ou_bot_${appId}`,
});
}
+1
View File
@@ -36,6 +36,7 @@ export const prisma = new PrismaClient({
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
const tables = [ const tables = [
"FeishuEventReceipt", "FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion", "FeishuApplicationCredentialVersion",
"OrganizationFeishuApplicationConnection", "OrganizationFeishuApplicationConnection",
"ProviderCredentialVersion", "ProviderCredentialVersion",
+40 -7
View File
@@ -4,15 +4,15 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID, testSecretEnvelope } from "./helpers.js";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
const ENV_KEYS = [ const ENV_KEYS = [
"DATABASE_URL", "DATABASE_URL",
"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"FEISHU_APP_ID", "HUB_SILO_ORGANIZATION_ID",
"FEISHU_APP_SECRET",
"FEISHU_BOT_OPEN_ID",
"HUB_PROJECT_WORKSPACE_ROOT", "HUB_PROJECT_WORKSPACE_ROOT",
"HUB_SESSION_SECRET", "HUB_SESSION_SECRET",
"HUB_PUBLIC_BASE_URL", "HUB_PUBLIC_BASE_URL",
@@ -20,6 +20,13 @@ const ENV_KEYS = [
"HUB_SECRET_KEYRING_FILE", "HUB_SECRET_KEYRING_FILE",
"HOST", "HOST",
"PORT", "PORT",
"HUB_HTTP_BODY_LIMIT_BYTES",
"HUB_MAX_FILES_PER_MESSAGE",
"HUB_MAX_FILE_BYTES",
"HUB_AGENT_MAX_CONCURRENT_RUNS",
"HUB_AGENT_MAX_RUN_SECONDS",
"HUB_HTTP_REQUESTS_PER_MINUTE",
"HUB_FEISHU_EVENTS_PER_MINUTE",
] as const; ] as const;
describe("Hub startup", () => { describe("Hub startup", () => {
@@ -34,6 +41,27 @@ describe("Hub startup", () => {
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => { it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
await resetDb(); await resetDb();
const owner = await prisma.user.create({
data: {
feishuOpenId: "legacy-server-start-owner",
displayName: "Server Start Owner",
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: owner.id,
appId: "test-app",
appSecret: "test-secret",
botOpenId: "ou_test_bot",
});
await new ProviderConnectionService(prisma, testSecretEnvelope, async () => {}).rotateByok({
organizationId: DEFAULT_ORG_ID,
actorUserId: owner.id,
providerId: "openrouter",
baseUrl: "https://openrouter.ai/api",
authToken: "test-provider-token",
});
const blocker = createServer(); const blocker = createServer();
blocker.listen(0, "127.0.0.1"); blocker.listen(0, "127.0.0.1");
await once(blocker, "listening"); await once(blocker, "listening");
@@ -50,9 +78,7 @@ describe("Hub startup", () => {
Object.assign(process.env, { Object.assign(process.env, {
DATABASE_URL: TEST_DATABASE_URL, DATABASE_URL: TEST_DATABASE_URL,
FEISHU_APP_ID: "test-app", HUB_SILO_ORGANIZATION_ID: DEFAULT_ORG_ID,
FEISHU_APP_SECRET: "test-secret",
FEISHU_BOT_OPEN_ID: "ou_test_bot",
HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test", HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test",
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes", HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
HUB_PUBLIC_BASE_URL: "https://hub.example.test", HUB_PUBLIC_BASE_URL: "https://hub.example.test",
@@ -60,6 +86,13 @@ describe("Hub startup", () => {
HUB_SECRET_KEYRING_FILE: keyringFile, HUB_SECRET_KEYRING_FILE: keyringFile,
HOST: "127.0.0.1", HOST: "127.0.0.1",
PORT: String(address.port), PORT: String(address.port),
HUB_HTTP_BODY_LIMIT_BYTES: "1048576",
HUB_MAX_FILES_PER_MESSAGE: "8",
HUB_MAX_FILE_BYTES: "26214400",
HUB_AGENT_MAX_CONCURRENT_RUNS: "1",
HUB_AGENT_MAX_RUN_SECONDS: "900",
HUB_HTTP_REQUESTS_PER_MINUTE: "120",
HUB_FEISHU_EVENTS_PER_MINUTE: "120",
}); });
try { try {
@@ -0,0 +1,57 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { bootstrapAlphaSilo } from "../../src/deployment/bootstrap-silo.js";
import { requireSiloOrganization } from "../../src/deployment/silo.js";
import { prisma, testSecretEnvelope } from "./helpers.js";
const input = {
organization: { id: "org_alpha", slug: "alpha", name: "Alpha School" },
owner: { openId: "ou_owner", displayName: "Owner" },
feishu: { appId: "cli_alpha", appSecret: "feishu-secret", botOpenId: "ou_bot" },
provider: {
providerId: "openrouter",
baseUrl: "https://openrouter.ai/api",
authToken: "provider-secret",
},
teams: [{ slug: "teachers", name: "Teachers" }],
} as const;
describe("Alpha Silo bootstrap", () => {
beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`);
});
afterAll(async () => {
await prisma.$disconnect();
});
it("creates one scoped Organization and is idempotent", async () => {
const probes = { feishu: async () => {}, provider: async () => {} };
const first = await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, probes);
const second = await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, probes);
expect(first).toMatchObject({ organizationId: "org_alpha", initialized: true, providerConfigured: true });
expect(second).toMatchObject({ organizationId: "org_alpha", initialized: false, providerConfigured: true });
await expect(requireSiloOrganization(prisma, "org_alpha")).resolves.toMatchObject({ slug: "alpha" });
expect(await prisma.organization.count()).toBe(1);
expect(await prisma.organizationMembership.count({ where: { role: "OWNER", revokedAt: null } })).toBe(1);
expect(await prisma.feishuUserIdentity.count()).toBe(1);
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
provider: await prisma.providerCredentialVersion.findMany(),
});
expect(persisted).not.toContain("feishu-secret");
expect(persisted).not.toContain("provider-secret");
});
it("rejects a configured Organization that does not match the database", async () => {
await bootstrapAlphaSilo(prisma, testSecretEnvelope, input, {
feishu: async () => {},
provider: async () => {},
});
await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch");
});
});
+6 -2
View File
@@ -22,7 +22,11 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
}; };
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> { function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps }); return makeProductionTriggerHandler({
projectWorkspaceRoot: "/tmp",
allowLegacyFeishuIdentity: true,
...deps,
});
} }
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
@@ -47,7 +51,7 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return models; return models;
}, },
async runPolicy() { async runPolicy() {
return { maxTurns: 7 }; return { maxTurns: 7, maxConcurrentRuns: 4, maxRunSeconds: 300 };
}, },
}; };
} }
+12 -5
View File
@@ -8,15 +8,21 @@ import {
const VALID_ENV = { const VALID_ENV = {
NODE_ENV: "production", NODE_ENV: "production",
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub", DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
FEISHU_APP_ID: "cli_app_id", HUB_SILO_ORGANIZATION_ID: "org_test",
FEISHU_APP_SECRET: "feishu-app-secret", HUB_SYSTEMD_UNIT: "cph-hub-test.service",
FEISHU_BOT_OPEN_ID: "ou_bot",
CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph", CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph",
HOST: "127.0.0.1", HOST: "127.0.0.1",
PORT: "8788", PORT: "8788",
HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces", HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces",
HUB_PUBLIC_BASE_URL: "https://hub.example.com", HUB_PUBLIC_BASE_URL: "https://hub.example.com",
HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes", HUB_SESSION_SECRET: "a-production-session-secret-with-32-bytes",
HUB_AGENT_MAX_CONCURRENT_RUNS: "1",
HUB_AGENT_MAX_RUN_SECONDS: "900",
HUB_HTTP_BODY_LIMIT_BYTES: "1048576",
HUB_MAX_FILES_PER_MESSAGE: "8",
HUB_MAX_FILE_BYTES: "26214400",
HUB_HTTP_REQUESTS_PER_MINUTE: "120",
HUB_FEISHU_EVENTS_PER_MINUTE: "120",
} as const; } as const;
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput { function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
@@ -27,6 +33,7 @@ function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPre
stateDir: "/var/lib/cph-hub/state", stateDir: "/var/lib/cph-hub/state",
cacheDir: "/var/cache/cph-hub", cacheDir: "/var/cache/cph-hub",
workspaceRoot: "/var/lib/cph-hub/workspaces", workspaceRoot: "/var/lib/cph-hub/workspaces",
expectedServiceUnit: "cph-hub-test.service",
env: VALID_ENV, env: VALID_ENV,
...overrides, ...overrides,
}; };
@@ -79,7 +86,7 @@ describe("validateDeploymentPreflight", () => {
input({ input({
env: { env: {
...VALID_ENV, ...VALID_ENV,
FEISHU_APP_SECRET: "", HUB_SILO_ORGANIZATION_ID: "",
HUB_PUBLIC_BASE_URL: "http://hub.example.com", HUB_PUBLIC_BASE_URL: "http://hub.example.com",
HUB_SESSION_SECRET: "short", HUB_SESSION_SECRET: "short",
}, },
@@ -89,7 +96,7 @@ describe("validateDeploymentPreflight", () => {
expect(error).toBeInstanceOf(DeploymentPreflightError); expect(error).toBeInstanceOf(DeploymentPreflightError);
expect((error as Error).message).toMatchInlineSnapshot(` expect((error as Error).message).toMatchInlineSnapshot(`
"deployment preflight failed: "deployment preflight failed:
- FEISHU_APP_SECRET is required - HUB_SILO_ORGANIZATION_ID is required
- HUB_PUBLIC_BASE_URL must use https - HUB_PUBLIC_BASE_URL must use https
- HUB_SESSION_SECRET must contain at least 32 characters" - HUB_SESSION_SECRET must contain at least 32 characters"
`); `);
+4 -2
View File
@@ -14,8 +14,10 @@ const larkMock = vi.hoisted(() => {
} }
} }
class MockWSClient { class MockWSClient {
constructor(private readonly params: { readonly onReady?: () => void }) {}
start(params: { readonly eventDispatcher?: unknown }): void { start(params: { readonly eventDispatcher?: unknown }): void {
starts.push(params); starts.push(params);
this.params.onReady?.();
} }
} }
return { starts, MockEventDispatcher, MockWSClient }; return { starts, MockEventDispatcher, MockWSClient };
@@ -40,7 +42,7 @@ describe("Feishu card action callbacks", () => {
const onCardAction = vi.fn(async () => action.promise); const onCardAction = vi.fn(async () => action.promise);
const logger = silentLogger(); const logger = silentLogger();
startFeishuListenerWithClient( await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(), fakeClient(),
logger, logger,
@@ -63,7 +65,7 @@ describe("Feishu card action callbacks", () => {
const logger = silentLogger(); const logger = silentLogger();
const err = new Error("handler failed"); const err = new Error("handler failed");
startFeishuListenerWithClient( await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(), fakeClient(),
logger, logger,
+21 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
buildAuthorizeUrl, buildAuthorizeUrl,
exchangeCodeForUser, exchangeCodeForUser,
FeishuOAuthError,
type FeishuOAuthConfig, type FeishuOAuthConfig,
} from "../../src/admin/auth/feishuOAuth.js"; } from "../../src/admin/auth/feishuOAuth.js";
@@ -75,13 +76,18 @@ describe("exchangeCodeForUser", () => {
it("throws on token exchange failure", async () => { it("throws on token exchange failure", async () => {
const fetchImpl = vi.fn(async () => const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), { new Response(JSON.stringify({
code: 20003,
error: "invalid_grant",
error_description: "echoed app secret s and authorization code bad",
}), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}), }),
); );
await expect( let failure: unknown;
exchangeCodeForUser( try {
await exchangeCodeForUser(
{ {
appId: "cli", appId: "cli",
appSecret: "s", appSecret: "s",
@@ -90,7 +96,17 @@ describe("exchangeCodeForUser", () => {
fetchImpl: fetchImpl as unknown as typeof fetch, fetchImpl: fetchImpl as unknown as typeof fetch,
}, },
"bad", "bad",
), );
).rejects.toThrow(/token exchange failed/); } catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(FeishuOAuthError);
expect(failure).toMatchObject({
code: "feishu_oauth_rejected",
category: "provider_rejection",
upstreamStatus: 200,
});
expect(String(failure)).not.toContain("secret s");
expect(String(failure)).not.toContain("authorization code bad");
}); });
}); });
+3 -1
View File
@@ -225,7 +225,7 @@ function mockSettings(): RuntimeSettings {
return registry; return registry;
}, },
async runPolicy() { async runPolicy() {
return { maxTurns: 1 }; return { maxTurns: 1, maxConcurrentRuns: 1, maxRunSeconds: 300 };
}, },
}; };
} }
@@ -270,6 +270,7 @@ function mockPrisma(): PrismaClient {
update: vi.fn(async () => session), update: vi.fn(async () => session),
}, },
agentRun: { agentRun: {
count: vi.fn(async () => 0),
create: vi.fn(async () => ({ id: "run-1" })), create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })), update: vi.fn(async () => ({ id: "run-1" })),
@@ -292,6 +293,7 @@ function mockPrisma(): PrismaClient {
Object.assign(client, { Object.assign(client, {
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)), $transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]), $queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
$executeRaw: vi.fn(async () => 1),
}); });
return client; return client;
} }
+16 -1
View File
@@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages } from "../../src/feishu/resourceStaging.js"; import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
const roots: string[] = []; const roots: string[] = [];
@@ -30,6 +31,20 @@ describe("Feishu resource staging recovery", () => {
/staging base is not a real directory/, /staging base is not a real directory/,
); );
}); });
it("rejects too many resources before contacting Feishu", async () => {
const root = await tempRoot();
await expect(stageMessageResources(
{} as FeishuRuntime,
"message-1",
[
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
{ fileKey: "b", resourceType: "file", workspaceRelativePath: "b" },
],
root,
{ maxFiles: 1, maxBytesPerFile: 10 },
)).rejects.toThrow("2 resources; limit is 1");
});
}); });
async function tempRoot(): Promise<string> { async function tempRoot(): Promise<string> {
+7 -1
View File
@@ -30,8 +30,14 @@ describe("runtime settings", () => {
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => { it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
const settings = createEnvRuntimeSettings({ const settings = createEnvRuntimeSettings({
HUB_AGENT_MAX_TURNS: "9", HUB_AGENT_MAX_TURNS: "9",
HUB_AGENT_MAX_CONCURRENT_RUNS: "2",
HUB_AGENT_MAX_RUN_SECONDS: "600",
}); });
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 }); await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({
maxTurns: 9,
maxConcurrentRuns: 2,
maxRunSeconds: 600,
});
}); });
}); });
+33
View File
@@ -46,6 +46,23 @@ describe("session cookie signing", () => {
const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET); const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET);
expect(verifySession(token, "other-secret")).toBeNull(); expect(verifySession(token, "other-secret")).toBeNull();
}); });
it("round-trips a connection-scoped identity", () => {
const token = signSession({
userId: "u-scoped",
feishuIdentityId: "identity-1",
feishuConnectionId: "connection-1",
feishuOrganizationId: "organization-1",
}, SECRET, 3600, 1_700_000_000);
expect(verifySession(token, SECRET, 1_700_000_100)).toEqual({
userId: "u-scoped",
feishuIdentityId: "identity-1",
feishuConnectionId: "connection-1",
feishuOrganizationId: "organization-1",
iat: 1_700_000_000,
exp: 1_700_003_600,
});
});
}); });
describe("oauth state signing", () => { describe("oauth state signing", () => {
@@ -62,6 +79,22 @@ describe("oauth state signing", () => {
exp: 1_700_000_600, exp: 1_700_000_600,
}); });
}); });
it("binds state to an Organization and connection as one inseparable scope", () => {
const token = signOAuthState({
nonce: "scoped",
returnTo: "/admin/org/acme",
organizationId: "org-acme",
connectionId: "connection-acme",
}, SECRET, 600, 1_700_000_000);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "scoped",
returnTo: "/admin/org/acme",
organizationId: "org-acme",
connectionId: "connection-acme",
exp: 1_700_000_600,
});
});
}); });
describe("sanitizeReturnTo", () => { describe("sanitizeReturnTo", () => {
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
describe("Silo fixed-window rate limiter", () => {
it("returns an explicit retry interval and resets at the next window", () => {
const limiter = new SiloFixedWindowRateLimiter(2, 60_000, 1_000);
expect(limiter.consume(1_000)).toEqual({ allowed: true });
expect(limiter.consume(2_000)).toEqual({ allowed: true });
expect(limiter.consume(3_000)).toEqual({ allowed: false, retryAfterSeconds: 58 });
expect(limiter.consume(61_000)).toEqual({ allowed: true });
});
});
@@ -0,0 +1,31 @@
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
const roots: string[] = [];
describe("inbound workspace file limits", () => {
const itOnLinux = process.platform === "linux" ? it : it.skip;
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
itOnLinux("fails and removes the partial file when the byte limit is exceeded", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-workspace-limit-"));
roots.push(root);
const workspace = join(root, "project");
await mkdir(workspace);
await expect(writeNewWorkspaceFileNoFollow(
root,
workspace,
"too-large.bin",
Readable.from([Buffer.from("1234")]),
3,
)).rejects.toMatchObject({ reason: "limit" });
await expect(access(join(workspace, "too-large.bin"))).rejects.toMatchObject({ code: "ENOENT" });
});
});