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
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: hub/package-lock.json
@@ -112,36 +112,6 @@ jobs:
env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
- name: Smoke health endpoint
run: |
PORT=8878 \
NODE_ENV=production \
DATABASE_URL=postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test \
ANTHROPIC_AUTH_TOKEN=smoke \
FEISHU_APP_ID=cli_smoke \
FEISHU_APP_SECRET=smoke_secret \
FEISHU_BOT_OPEN_ID=ou_smoke \
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
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
- name: Run real-model integration tests (optional, needs secret)
+4
View File
@@ -24,6 +24,10 @@
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 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_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_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
# absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Feishu (lark) bot credentials.
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID=""
# This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# 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,
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap,
`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
create or run the unit as root.
The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
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
sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/hub \
bash /srv/curriculum-project-hub/hub/deploy/install_service.sh
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
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
`/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned
`secret-keyring.json`, then exits with status 78. Copy the keyring to a
separately protected recovery location before continuing; it must not live only
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:
The first invocation creates root-owned `0600` files below
`/srv/curriculum-project-hub/.secrets/org-a/` and exits 78. Copy the keyring to
a separately protected recovery destination and fill every blank in
`platform.env`. Before rerunning the installer, migrate the empty Silo database:
```sh
sudo bash -c '
set -euo pipefail
systemctl stop cph-hub.service
set -a
. /srv/curriculum-project-hub/.secrets/platform.env
set +a
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
set -a; . /srv/curriculum-project-hub/.secrets/org-a/platform.env; set +a
node /srv/curriculum-project-hub/releases/<release-id>/hub/node_modules/prisma/build/index.js \
migrate deploy \
--schema /srv/curriculum-project-hub/releases/<release-id>/hub/prisma/schema.prisma
'
```
The CLI refuses to rotate while `cph-hub.service` is active; stopping the only
writer prevents a concurrent BYOK version from being appended under the old
in-memory keyring. A PostgreSQL transaction advisory lock excludes a second
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.
Then rerun the installer so it can complete preflight and install the stopped
unit. Resource numbers above are examples, not defaults; the operator must set
measured ceilings explicitly.
The deployment path also runs `npm run audit:production` from the locked clean
install before building or restarting the service. A current high or critical
production advisory therefore blocks deployment instead of becoming a warning
buried in CI output.
The installer refuses overlapping release/persistent paths, root service users,
missing Node/cph/PostgreSQL/bubblewrap prerequisites, invalid credentials, and
unreachable database roles. The installed unit is `cph-hub-org-a.service` and
uses systemd `LoadCredential`; the service user cannot read the source keyring.
An existing service account is accepted only when its primary group, home and
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:
Default state paths are:
```text
/var/lib/cph-hub/home
/var/lib/cph-hub/state
/var/lib/cph-hub/workspaces
/var/cache/cph-hub
/var/lib/cph-hub/org-a/home
/var/lib/cph-hub/org-a/state
/var/lib/cph-hub/org-a/workspaces
/var/cache/cph-hub/org-a
```
Custom paths are supported with `SERVICE_HOME`, `STATE_DIR`, `CACHE_DIR` and
`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`.
## Bootstrap the only Organization
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
sudo systemctl start cph-hub.service
sudo systemctl status cph-hub.service
sudo bash -c '
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
default `127.0.0.1:8788` expects a local TLS reverse proxy; the public OAuth URL
must be an HTTPS `HUB_PUBLIC_BASE_URL`.
Bootstrap encrypts and activates both connection records and is rerunnable when
provider configuration fails after Organization creation. Hub startup refuses
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]
Description=Curriculum Project Hub (Feishu agent + cph)
Description=Curriculum Project Hub Silo (__INSTANCE_ID__)
After=network-online.target postgresql.service
Wants=network-online.target
# 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
Restart=on-failure
RestartSec=5
MemoryMax=__MEMORY_MAX__
CPUQuota=__CPU_QUOTA__
TasksMax=__TASKS_MAX__
OOMPolicy=stop
# Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM
TimeoutStopSec=30
+38 -14
View File
@@ -5,51 +5,75 @@
# PLATFORM_DEPLOY_HOST required
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
# PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_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_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
# 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),
# and a compatible `cph` binary named by platform.env. The service installer
# provisions the dedicated non-root identity and persistent directories.
# 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
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
HUB_PORT="${PLATFORM_DEPLOY_HUB_PORT:?PLATFORM_DEPLOY_HUB_PORT required}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
HUB_DIR="$BASE/hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
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)
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
# 1. Populate a new release directory. A completed release is never mutated.
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps, prove the production dependency tree, then build on the host.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build"
# 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 && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart cph-hub.service
systemctl is-active --quiet cph-hub.service
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 '$SERVICE_UNIT'
systemctl is-active --quiet '$SERVICE_UNIT'
else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart cph-hub.service
sudo -n systemctl is-active --quiet cph-hub.service
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 '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi
for attempt in \$(seq 1 30); do
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}"
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_HOME="${SERVICE_HOME:-/var/lib/cph-hub/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}"
PORT="${PORT:?PORT is required and must be unique on the host}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
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}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
@@ -108,6 +117,9 @@ for pair in \
"RUNTIME_PATH:$RUNTIME_PATH"; do
require_safe_unit_value "${pair%%:*}" "${pair#*:}"
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
if [ -L "$KEYRING_FILE" ]; then
@@ -146,9 +158,8 @@ if [ ! -e "$ENV_FILE" ]; then
# rerunning install_service.sh; failed preflight never installs the unit.
NODE_ENV=production
DATABASE_URL=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
HUB_SILO_ORGANIZATION_ID=
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
CPH_BIN=$CPH_BIN_DEFAULT
HOST=$HOST
PORT=$PORT
@@ -156,6 +167,13 @@ HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET=
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
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE
EOF
@@ -185,6 +203,7 @@ PREFLIGHT_ARGS=(
--state-dir "$STATE_DIR"
--cache-dir "$CACHE_DIR"
--workspace-root "$WORKSPACE_ROOT"
--service-unit "$SERVICE_UNIT"
--bwrap-bin "$BWRAP_BIN"
--socat-bin "$SOCAT_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.
"${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"
TMP_UNIT="$(mktemp)"
trap 'rm -f "$TMP_UNIT"' EXIT
@@ -285,11 +304,15 @@ sed \
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
-e "s|__SOCAT_BIN__|$SOCAT_BIN|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"
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload
systemctl enable cph-hub.service
echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP"
systemctl enable "$SERVICE_UNIT"
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] 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"
},
"engines": {
"node": ">=20"
"node": ">=24"
}
},
"node_modules/@ai-sdk/gateway": {
+3 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=20"
"node": ">=24"
},
"dependencies": {
"@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: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",
"silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"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)
}
/// 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 {
id String @id @default(cuid())
feishuOpenId String @unique
@@ -111,6 +113,7 @@ model User {
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[]
}
/// 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)
secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
userIdentities FeishuUserIdentity[]
@@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
/// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion {
@@ -237,8 +262,8 @@ enum PlatformRole {
}
/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
/// principals by their Feishu ids.
/// USER and Feishu external principals use connection-scoped opaque ids, never
/// raw provider-local ids; TEAM uses Hub Team.id.
enum PrincipalType {
USER
TEAM
+104 -37
View File
@@ -1,14 +1,16 @@
/**
* Feishu web OAuth (user login) for org admin panel.
*
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot).
* Per-org encrypted Feishu app secrets are deferred (ADR-0021).
* Organization login resolves the intended encrypted Feishu Application
* Connection before building the authorize URL or exchanging the code.
*
* Flow:
* 1. GET authorize URL → user consents
* 2. callback code → user_access_token (authen/v2/oauth/token)
* 3. user_access_token → user info (authen/v1/user_info) → open_id
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../../connections/networkFailure.js";
export interface FeishuOAuthConfig {
readonly appId: string;
@@ -24,10 +26,23 @@ export interface FeishuOAuthConfig {
export interface FeishuOAuthUser {
readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string;
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_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";
@@ -59,7 +74,9 @@ export async function exchangeCodeForUser(
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
const fetchImpl = config.fetchImpl ?? fetch;
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
const res = await fetchImpl(tokenUrl, {
let res: Response;
try {
res = await fetchImpl(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
@@ -69,56 +86,106 @@ async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: strin
code,
redirect_uri: config.redirectUri,
}),
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
const body = (await res.json()) as {
code?: number;
access_token?: string;
error?: string;
error_description?: string;
msg?: string;
};
if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") {
const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`;
throw new Error(`Feishu OAuth token exchange failed: ${detail}`);
} catch (error) {
throw new FeishuOAuthError(
"feishu_oauth_unreachable",
"Feishu OAuth token exchange could not reach provider",
classifyNetworkFailure(error),
);
}
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> {
const fetchImpl = config.fetchImpl ?? fetch;
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
const res = await fetchImpl(userInfoUrl, {
let res: Response;
try {
res = await fetchImpl(userInfoUrl, {
method: "GET",
headers: { Authorization: `Bearer ${userAccessToken}` },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
const body = (await res.json()) as {
code?: number;
msg?: string;
data?: {
open_id?: string;
name?: string;
en_name?: string;
avatar_url?: string;
avatar_middle?: string;
};
};
if (!res.ok || body.code !== 0 || body.data === undefined) {
throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`);
} catch (error) {
throw new FeishuOAuthError(
"feishu_oauth_unreachable",
"Feishu OAuth user info could not reach provider",
classifyNetworkFailure(error),
);
}
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 === "") {
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 =
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ??
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ??
(typeof data["name"] === "string" && data["name"] !== "" ? data["name"] : null) ??
(typeof data["en_name"] === "string" && data["en_name"] !== "" ? data["en_name"] : null) ??
openId;
const avatar =
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== ""
? body.data.avatar_url
(typeof data["avatar_url"] === "string" && data["avatar_url"] !== ""
? data["avatar_url"]
: null) ??
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
? body.data.avatar_middle
(typeof data["avatar_middle"] === "string" && data["avatar_middle"] !== ""
? data["avatar_middle"]
: 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 {
readonly session: SessionPayload;
readonly user: User;
readonly feishuOpenId: string;
readonly authenticationOrganizationId?: string | undefined;
}
export interface OrgAuthContext extends AuthContext {
@@ -55,12 +57,45 @@ export async function requireSession(
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
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 } });
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
await sendError(reply, 401, "unauthenticated", "session user not found");
return null;
}
return { session, user };
return { session, user, feishuOpenId: user.feishuOpenId };
}
export async function requireOrgRole(
@@ -87,6 +122,11 @@ export async function requireOrgRole(
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
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({
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. */
export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
export interface SessionPayload {
readonly userId: string;
readonly feishuOpenId: string;
interface SessionTimes {
readonly iat: 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 {
readonly nonce: string;
readonly returnTo: string;
readonly organizationId?: string | undefined;
readonly connectionId?: string | undefined;
readonly exp: number;
}
export function signSession(
payload: Omit<SessionPayload, "iat" | "exp">,
payload: SessionIdentity,
secret: string,
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: SessionPayload = {
userId: payload.userId,
feishuOpenId: payload.feishuOpenId,
...payload,
iat: nowSeconds,
exp: nowSeconds + ttlSeconds,
};
@@ -48,15 +61,38 @@ export function verifySession(
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): SessionPayload | null {
const payload = verifyJson<SessionPayload>(token, secret);
const payload = verifyJson<Record<string, unknown>>(token, secret);
if (payload === null) return null;
if (typeof payload.userId !== "string" || payload.userId === "") return null;
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
if (payload.exp <= nowSeconds) return null;
return payload;
if (typeof payload["userId"] !== "string" || payload["userId"] === "") return null;
if (typeof payload["iat"] !== "number" || typeof payload["exp"] !== "number") return null;
if (payload["exp"] <= nowSeconds) return null;
const legacy = typeof payload["feishuOpenId"] === "string" && payload["feishuOpenId"] !== "";
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(
payload: Omit<OAuthStatePayload, "exp">,
secret: string,
@@ -64,8 +100,7 @@ export function signOAuthState(
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: OAuthStatePayload = {
nonce: payload.nonce,
returnTo: payload.returnTo,
...payload,
exp: nowSeconds + ttlSeconds,
};
return signJson(full, secret);
@@ -81,7 +116,17 @@ export function verifyOAuthState(
if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
if (typeof payload.returnTo !== "string") return null;
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
return payload;
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 {
+5
View File
@@ -23,6 +23,7 @@ export interface AdminPluginConfig {
readonly cookieSecure?: boolean;
readonly oauthScope?: string;
readonly fetchImpl?: typeof fetch;
readonly allowLegacyFeishuOAuth?: boolean;
}
export async function registerAdminPlugin(
@@ -40,9 +41,13 @@ export async function registerAdminPlugin(
publicBaseUrl: config.publicBaseUrl,
feishuAppId: config.feishuAppId,
feishuAppSecret: config.feishuAppSecret,
secretEnvelope: config.secretEnvelope,
cookieSecure,
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
...(config.allowLegacyFeishuOAuth !== undefined
? { allowLegacyFeishuOAuth: config.allowLegacyFeishuOAuth }
: {}),
});
await registerOrgRoutes(app, {
+165 -24
View File
@@ -4,10 +4,14 @@
import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
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 {
buildAuthorizeUrl,
DEFAULT_OAUTH_SCOPE,
exchangeCodeForUser,
FeishuOAuthError,
type FeishuOAuthConfig,
} from "../auth/feishuOAuth.js";
import {
@@ -21,6 +25,7 @@ import {
signOAuthState,
signSession,
verifyOAuthState,
type SessionIdentity,
} from "../auth/session.js";
import { handleRouteError } from "../errors.js";
@@ -30,15 +35,17 @@ export interface AuthRouteConfig {
readonly publicBaseUrl: string;
readonly feishuAppId: string;
readonly feishuAppSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly oauthScope?: string;
readonly cookieSecure: boolean;
readonly fetchImpl?: typeof fetch;
readonly allowLegacyFeishuOAuth?: boolean;
}
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
const oauthConfig: FeishuOAuthConfig = {
const legacyOauthConfig: FeishuOAuthConfig = {
appId: config.feishuAppId,
appSecret: config.feishuAppSecret,
redirectUri,
@@ -46,6 +53,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
};
if (config.allowLegacyFeishuOAuth === true) {
app.get("/auth/feishu", async (request, reply) => {
try {
const returnTo = sanitizeReturnTo(
@@ -55,20 +63,55 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
);
const nonce = randomBytes(16).toString("hex");
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit.
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
maxAge: 600,
});
const url = buildAuthorizeUrl(oauthConfig, stateToken);
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
const url = buildAuthorizeUrl(legacyOauthConfig, stateToken);
return reply.redirect(url);
} catch (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) => {
try {
@@ -96,7 +139,49 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
}
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);
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({
where: { feishuOpenId: feishuUser.openId },
create: {
@@ -109,20 +194,38 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
avatarUrl: feishuUser.avatarUrl,
},
});
userId = user.id;
setSessionCookie(reply, config, {
userId: user.id,
userId,
feishuOpenId: user.feishuOpenId,
});
}
const destination = await resolvePostLoginRedirect(
config.prisma,
user.id,
userId,
statePayload.returnTo,
statePayload.organizationId !== undefined
? { intendedOrganizationId: statePayload.organizationId }
: {},
);
return reply.redirect(destination);
} 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")}`);
}
});
@@ -138,7 +241,13 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
if (auth === null) return;
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: {
role: true,
organization: {
@@ -151,7 +260,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return {
user: {
id: auth.user.id,
feishuOpenId: auth.user.feishuOpenId,
feishuOpenId: auth.feishuOpenId,
displayName: auth.user.displayName,
avatarUrl: auth.user.avatarUrl,
},
@@ -170,12 +279,16 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
// Minimal login page until admin SPA lands (PR7).
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
const q = request.query as { error?: string; returnTo?: string };
const q = request.query as { error?: string; returnTo?: string; orgSlug?: string };
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
const loginHref =
returnTo === "/admin"
? "/auth/feishu"
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`;
const orgSlug = typeof q.orgSlug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(q.orgSlug)
? q.orgSlug
: null;
const loginHref = orgSlug === null
? null
: returnTo === "/admin"
? `/auth/feishu/${encodeURIComponent(orgSlug)}`
: `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
const errorHtml =
typeof q.error === "string" && q.error !== ""
? `<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>
<p>Sign in with Feishu to manage your organization.</p>
${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>
</body>
</html>`;
@@ -210,7 +325,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
export function setSessionCookie(
reply: FastifyReply,
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
identity: { readonly userId: string; readonly feishuOpenId: string },
identity: SessionIdentity,
): void {
const token = signSession(identity, config.sessionSecret);
reply.setCookie(SESSION_COOKIE_NAME, token, {
@@ -224,7 +339,7 @@ export function setSessionCookie(
/** Exported for tests that need a pre-authenticated cookie value. */
export function mintSessionToken(
identity: { readonly userId: string; readonly feishuOpenId: string },
identity: SessionIdentity,
sessionSecret: string,
): string {
return signSession(identity, sessionSecret);
@@ -238,7 +353,22 @@ async function resolvePostLoginRedirect(
prisma: PrismaClient,
userId: string,
returnTo: string,
options: { readonly intendedOrganizationId?: string | undefined } = {},
): 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")) {
return returnTo;
}
@@ -267,6 +397,17 @@ async function resolvePostLoginRedirect(
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.
*/
+2 -2
View File
@@ -130,7 +130,7 @@ export async function registerExplorerRoutes(
}
const result = await createOrgProject(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.user.feishuOpenId,
actorFeishuOpenId: auth.feishuOpenId,
name: body.name,
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
@@ -219,7 +219,7 @@ export async function registerExplorerRoutes(
return await archiveOrgProjectChatBinding(config.prisma, {
organizationId: auth.organization.id,
projectId,
actorFeishuOpenId: auth.user.feishuOpenId,
actorFeishuOpenId: auth.feishuOpenId,
});
} catch (err) {
return handleRouteError(reply, err);
@@ -32,7 +32,7 @@ export interface ResolvedFeishuApplication extends Required<FeishuApplicationCre
readonly organizationId: string;
}
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
export interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
readonly schemaVersion: 1;
}
@@ -46,8 +46,8 @@ export class FeishuApplicationConnectionService {
async rotateCustomerApplication(
input: RotateFeishuApplicationInput,
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
const payload = validateCredential(input);
const appIdentityFingerprint = fingerprintAppId(payload.appId);
const payload = validateFeishuApplicationCredential(input);
const appIdentityFingerprint = fingerprintFeishuAppId(payload.appId);
await this.prisma.$transaction(async (tx) => {
await requireFeishuAdmin(tx, input);
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
@@ -287,7 +287,7 @@ export function decryptFeishuCredential(
secretVersionId: input.secretVersionId,
}, envelope);
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");
}
return validated;
@@ -387,7 +387,7 @@ export async function rewrapStoredFeishuApplicationEnvelopes(
return { verified, rewrapped };
}
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
export function validateFeishuApplicationCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
const appId = nonEmpty(input.appId, "Feishu appId");
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
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");
}
+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 cacheDir: string;
readonly workspaceRoot: string;
readonly serviceUnit: string;
readonly bwrapBin: string;
readonly socatBin: string;
readonly pgIsReadyBin: string;
@@ -79,6 +80,7 @@ function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPrefli
stateDir: options.stateDir,
cacheDir: options.cacheDir,
workspaceRoot: options.workspaceRoot,
expectedServiceUnit: options.serviceUnit,
env,
};
}
@@ -97,6 +99,7 @@ async function canonicalizeInput(input: DeploymentPreflightInput): Promise<Deplo
stateDir,
cacheDir,
workspaceRoot,
expectedServiceUnit: input.expectedServiceUnit,
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 major = Number(version.split(".")[0]);
if (!Number.isInteger(major) || major < 20) {
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`);
if (!Number.isInteger(major) || major < 24) {
throw new Error(`Node.js 24 or newer is required; found ${version || "unknown"}`);
}
console.log(`[preflight] Node.js: ${version}`);
}
@@ -479,6 +482,7 @@ function parseArgs(argv: readonly string[]): Options {
"--state-dir",
"--cache-dir",
"--workspace-root",
"--service-unit",
"--bwrap-bin",
"--socat-bin",
"--pg-isready-bin",
@@ -508,6 +512,7 @@ function parseArgs(argv: readonly string[]): Options {
stateDir: required(values, "--state-dir"),
cacheDir: required(values, "--cache-dir"),
workspaceRoot: required(values, "--workspace-root"),
serviceUnit: required(values, "--service-unit"),
bwrapBin: required(values, "--bwrap-bin"),
socatBin: required(values, "--socat-bin"),
pgIsReadyBin: required(values, "--pg-isready-bin"),
+24 -3
View File
@@ -10,6 +10,7 @@ export interface DeploymentPreflightInput {
readonly stateDir: string;
readonly cacheDir: string;
readonly workspaceRoot: string;
readonly expectedServiceUnit: string;
readonly env: DeploymentEnv;
}
@@ -89,9 +90,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
}
}
readRequired(input.env, "FEISHU_APP_ID", problems);
readRequired(input.env, "FEISHU_APP_SECRET", problems);
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems);
readRequired(input.env, "HUB_SILO_ORGANIZATION_ID", problems);
const serviceUnit = readRequired(input.env, "HUB_SYSTEMD_UNIT", 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);
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
@@ -99,6 +104,7 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
}
let bind: ServerBinding | undefined;
readRequired(input.env, "PORT", problems);
try {
bind = readServerBinding(input.env);
} catch (error) {
@@ -116,6 +122,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
}
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);
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 {
const raw = env[name]?.trim().toLowerCase();
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";
const execFileAsync = promisify(execFile);
const SERVICE_UNIT = "cph-hub.service";
async function main(): Promise<void> {
if (process.getuid?.() !== 0) {
@@ -30,22 +29,31 @@ async function main(): Promise<void> {
}
async function requireStoppedService(): Promise<void> {
const serviceUnit = readServiceUnit();
let activeState: string;
try {
const { stdout } = await execFileAsync(
"systemctl",
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
["show", "--property=ActiveState", "--value", serviceUnit],
{ timeout: 10_000 },
);
activeState = stdout.trim();
} 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") {
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 {
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");
+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 {
readonly client: lark.Client;
readonly logger: FastifyBaseLogger;
readonly isListenerReady?: () => boolean;
}
export interface OutboundPayload {
@@ -494,6 +495,7 @@ export async function downloadMessageFile(
workspaceDir: string,
workspaceRelativePath: string,
resourceType: "image" | "file",
maxBytes?: number,
): Promise<string> {
try {
return await withRetry(async () => {
@@ -509,6 +511,7 @@ export async function downloadMessageFile(
workspaceDir,
workspaceRelativePath,
source,
maxBytes,
);
} catch (error) {
source.destroy();
@@ -911,20 +914,41 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
});
}
export function startFeishuListenerWithClient(
export async function startFeishuListenerWithClient(
config: FeishuConfig,
client: lark.Client,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, 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({
appId: config.appId,
appSecret: config.appSecret,
domain: lark.Domain.Feishu,
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>> = {
"im.message.receive_v1": async (data) => {
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"); });
};
}
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;
}
export function startFeishuListener(
export async function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
): Promise<FeishuRuntime> {
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,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number },
): Promise<StagedMessageResourceBatch | 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 stagingRoot = join(stagingBase, randomUUID());
const resources: StagedMessageResource[] = [];
@@ -53,6 +57,7 @@ export async function stageMessageResources(
stagingRoot,
`resource-${index}`,
request.resourceType,
limits?.maxBytesPerFile,
);
resources.push({
resourceType: request.resourceType,
+132 -11
View File
@@ -68,6 +68,7 @@ import {
type OnboardingProjectOption,
type ProjectOnboardingActionValue,
} from "./projectOnboardingCard.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -84,6 +85,15 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
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 {
@@ -102,6 +112,13 @@ interface TriggerRunContext {
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 {
(event: MessageReceiveEvent, 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 approvalManager = new ApprovalManager();
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
? undefined
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
const slashCommands = createSlashCommandRegistry({
prisma: deps.prisma,
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.
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
@@ -224,7 +248,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
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 promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
@@ -239,6 +268,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
try {
admission = await deps.prisma.$transaction(async (tx) => {
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
// 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);
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;
const current = await currentLockRunId(deps.prisma, projectId);
if (current === null) throw error;
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked";
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
// stop the SDK query. Cleared in the finally below.
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);
const agentExecution = (async () => {
const providerLease = await provider.openAgentLease({ runId: run.id });
@@ -477,7 +529,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
})();
agentExecution
.then(async (result) => {
const interrupted = result.status === "interrupted";
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const finalText =
result.text !== ""
? result.text
@@ -495,7 +547,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await deps.prisma.agentRun.update({
where: { id: run.id },
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,
outputTokens: result.usage.outputTokens,
...(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) } });
})
.finally(async () => {
clearTimeout(wallTimeTimer);
try {
await releaseLock(deps.prisma, run.id);
} catch (e) {
@@ -621,6 +682,18 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
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).
const approvalAction = approvalActionFromValue(event.action.value);
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.
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
const chatId = msg.chat_id;
@@ -913,6 +1008,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (msg.message_type === "text") {
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptionsForTriggerMessage(msg));
return;
}
await enqueueLockedTrigger(runContext, cleanPrompt);
return;
}
@@ -975,10 +1074,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
| { readonly status: "error"; readonly message: string }
> {
const user = await deps.prisma.user.findUnique({
where: { feishuOpenId },
const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: {
openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
},
select: {
organizationMemberships: {
user: { select: { organizationMemberships: {
where: {
revokedAt: null,
organization: { status: "ACTIVE" },
@@ -988,12 +1090,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
organization: { select: { id: true, name: true } },
},
orderBy: { createdAt: "asc" },
},
} } },
},
});
if (user === null) {
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
}
const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
? 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) {
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
}
@@ -1378,6 +1490,7 @@ async function stageTriggerMessageResources(
rt: FeishuRuntime,
msg: MessageReceiveEvent["message"],
workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"],
): Promise<StagedMessageResourceBatch | null> {
const requests: MessageResourceStageRequest[] = [];
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(
+96 -13
View File
@@ -10,7 +10,10 @@ import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
import { openProviderProxyLease } from "./connections/providerProxy.js";
import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js";
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.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 {
const value = process.env[name];
@@ -30,7 +33,23 @@ function booleanEnv(name: string, fallback: boolean): boolean {
export async function startHub(): Promise<void> {
requireEnv("DATABASE_URL");
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);
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 feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const siloOrganizationId = readSiloOrganizationId();
const siloOrganization = await requireSiloOrganization(prisma, siloOrganizationId);
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 publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding();
@@ -74,24 +123,30 @@ export async function startHub(): Promise<void> {
});
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, {
prisma,
sessionSecret,
publicBaseUrl,
feishuAppId,
feishuAppSecret,
feishuAppId: feishuApplication.appId,
feishuAppSecret: feishuApplication.appSecret,
projectWorkspaceRoot,
secretEnvelope,
});
const address = await app.listen(bind);
app.log.info({ address }, "hub listening");
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
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 triggerQueuePurgeTimer = setInterval(() => {
const removed = triggerQueue.purgeExpired();
@@ -100,9 +155,37 @@ export async function startHub(): Promise<void> {
}
}, 60_000);
triggerQueuePurgeTimer.unref();
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
const trigger = makeTriggerHandler({
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 {
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.
*/
import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client";
import { upsertScopedFeishuIdentityInTransaction } from "../feishu/identityNamespace.js";
import { lockActiveOrganization } from "./status.js";
export interface OrgMemberRow {
readonly userId: string;
readonly feishuOpenId: string;
readonly feishuOpenId: string | null;
readonly identityStatus: "SCOPED" | "UNLINKED";
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrganizationMemberRole;
@@ -29,14 +31,24 @@ export async function listOrgMembers(
role: true,
createdAt: true,
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" }],
});
return rows.map((row) => ({
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,
avatarUrl: row.user.avatarUrl,
role: row.role,
@@ -57,39 +69,47 @@ export async function addOrgMember(
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
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);
const user = await tx.user.upsert({
where: { feishuOpenId: openId },
create: {
feishuOpenId: openId,
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
where: { organizationId: input.organizationId },
select: {
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,
},
update: {
...(input.displayName !== undefined && input.displayName.trim() !== ""
? { displayName: input.displayName.trim() }
: {}),
},
});
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({
data: {
organizationId: input.organizationId,
userId: user.id,
userId: identity.userId,
role: input.role,
},
});
return { user, membership };
return { identity, membership };
});
return {
userId: user.id,
feishuOpenId: user.feishuOpenId,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
userId: identity.userId,
feishuOpenId: identity.openId,
identityStatus: "SCOPED",
displayName: identity.displayName,
avatarUrl: identity.avatarUrl,
role: membership.role,
createdAt: membership.createdAt.toISOString(),
};
@@ -118,7 +138,16 @@ export async function setOrgMemberRole(
role: true,
createdAt: true,
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 {
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,
avatarUrl: membership.user.avatarUrl,
role: updated.role,
+47 -12
View File
@@ -168,16 +168,30 @@ export async function listTeamMembers(
where: { teamId: input.teamId, revokedAt: null },
select: {
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" },
});
return rows.map((row) => ({
return rows.map((row) => {
const identity = row.user.feishuIdentities[0];
return {
userId: row.user.id,
feishuOpenId: row.user.feishuOpenId,
feishuOpenId: identity?.openId ?? row.user.feishuOpenId,
displayName: row.user.displayName,
createdAt: row.createdAt.toISOString(),
}));
};
});
}
export async function addTeamMember(
@@ -244,23 +258,44 @@ export async function revokeTeamMember(
async function resolveUser(
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 }> {
if (input.userId !== undefined && input.userId !== "") {
const user = await prisma.user.findUnique({
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}`);
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 !== "") {
const user = await prisma.user.findUnique({
where: { feishuOpenId: input.feishuOpenId },
select: { id: true, feishuOpenId: true, displayName: true },
const identity = await prisma.feishuUserIdentity.findFirst({
where: {
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}`);
return user;
if (identity === null) throw new Error(`user not found in organization: ${input.feishuOpenId}`);
return { id: identity.user.id, feishuOpenId: identity.openId, displayName: identity.user.displayName };
}
throw new Error("userId or feishuOpenId is required");
}
+49 -7
View File
@@ -1,4 +1,5 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
import { scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
export interface PrincipalRef {
readonly type: PrincipalType;
@@ -40,19 +41,60 @@ export interface 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> {
const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
}
const user = await this.prisma.user.findUnique({
const scopedIdentity = await this.prisma.feishuUserIdentity.findFirst({
where: {
openId: actor.feishuOpenId,
connection: {
organizationId: scope.organizationId,
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 },
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) {
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 { writeAudit } from "./audit.js";
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
import { scopedFeishuPrincipalId } from "./feishu/identityNamespace.js";
export interface OrganizationProjectPolicy {
readonly organizationId: string;
@@ -140,7 +141,8 @@ export async function createProjectFromOrgAdmin(
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
actorPrincipalId: actor.principalId,
feishuConnectionId: actor.connectionId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
@@ -170,7 +172,8 @@ export async function createProjectFromFeishuChat(
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
actorPrincipalId: actor.principalId,
feishuConnectionId: actor.connectionId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
@@ -212,7 +215,7 @@ export async function bindFeishuChatToProject(
await replaceProjectGrant(tx, {
projectId: project.id,
principalType: "FEISHU_CHAT",
principalId: chatId,
principalId: scopedChatPrincipalId(actor.connectionId, chatId),
role: "EDIT",
createdByUserId: actor.userId,
});
@@ -262,7 +265,7 @@ export async function archiveFeishuChatBinding(
resourceType: "PROJECT",
resourceId: binding.projectId,
principalType: "FEISHU_CHAT",
principalId: binding.chatId,
principalId: scopedChatPrincipalId(actor.connectionId, binding.chatId),
revokedAt: null,
},
data: { revokedAt: new Date() },
@@ -282,7 +285,8 @@ async function createManagedProject(
input: {
readonly organizationId: string;
readonly actorUserId: string;
readonly actorFeishuOpenId: string;
readonly actorPrincipalId: string;
readonly feishuConnectionId: string | undefined;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId: string | undefined;
@@ -332,7 +336,7 @@ async function createManagedProject(
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "USER",
principalId: input.actorFeishuOpenId,
principalId: input.actorPrincipalId,
role: "MANAGE",
createdByUserId: input.actorUserId,
});
@@ -343,7 +347,7 @@ async function createManagedProject(
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "FEISHU_CHAT",
principalId: input.chatId,
principalId: scopedChatPrincipalId(input.feishuConnectionId, input.chatId),
role: "EDIT",
createdByUserId: input.actorUserId,
});
@@ -385,7 +389,13 @@ async function requireProjectManager(
projectId: string,
organizationId: 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);
if (isOrgAdminRole(actor.role)) {
return { ...actor, via: "org-admin" };
@@ -405,9 +415,42 @@ async function requireActiveOrgMember(
prisma: PrismaClient,
organizationId: 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);
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 },
select: {
id: true,
@@ -425,7 +468,11 @@ async function requireActiveOrgMember(
if (membership === undefined) {
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(
+13 -1
View File
@@ -7,7 +7,7 @@ export class WorkspaceFileBoundaryError extends Error {
constructor(
message: string,
readonly requestedPath: string,
readonly reason: "not_found" | "boundary" | "io" = "boundary",
readonly reason: "not_found" | "boundary" | "io" | "limit" = "boundary",
options?: ErrorOptions,
) {
super(message, options);
@@ -77,12 +77,14 @@ export async function writeNewWorkspaceFileNoFollow(
workspaceDir: string,
requestedPath: string,
source: Readable,
maxBytes?: number,
): Promise<string> {
return (await writeNewWorkspaceFileNoFollowTracked(
workspaceRoot,
workspaceDir,
requestedPath,
source,
maxBytes,
)).path;
}
@@ -92,6 +94,7 @@ export async function writeNewWorkspaceFileNoFollowTracked(
workspaceDir: string,
requestedPath: string,
source: Readable,
maxBytes?: number,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
@@ -114,8 +117,17 @@ export async function writeNewWorkspaceFileNoFollowTracked(
const metadata = await file.stat();
device = metadata.dev;
inode = metadata.ino;
let bytesWritten = 0;
for await (const chunk of source) {
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;
while (offset < buffer.length) {
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/*`.
*
* 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.
* Provider credentials are org-scoped encrypted records; production receives
* 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_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25;
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
export interface ProviderRuntimeSettings {
readonly id: string;
@@ -43,6 +45,8 @@ export interface RunPolicyInput {
export interface RunPolicy {
readonly maxTurns: number;
readonly maxConcurrentRuns: number;
readonly maxRunSeconds: number;
}
export interface RuntimeSettings {
@@ -75,6 +79,16 @@ export class EnvRuntimeSettings implements RuntimeSettings {
void input;
return {
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,
} from "../../src/admin/routes/authRoutes.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 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 });
await registerAdminPlugin(app, {
prisma,
@@ -24,6 +26,7 @@ async function buildApp(fetchImpl?: typeof fetch) {
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
secretEnvelope: testSecretEnvelope,
allowLegacyFeishuOAuth,
cookieSecure: false,
...(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 () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
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 () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
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 { addOrgMember } from "../../src/org/members.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";
@@ -51,6 +52,14 @@ describe("admin members + teams API", () => {
it("adds members and enforces last-OWNER protection", async () => {
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();
try {
const cookie = sessionCookieHeader(token);
@@ -64,6 +73,14 @@ describe("admin members + teams API", () => {
expect(add.statusCode).toBe(201);
const teacher = add.json() as { userId: string; role: string };
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({
method: "GET",
@@ -71,7 +88,18 @@ describe("admin members + teams API", () => {
headers: { cookie },
});
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({
method: "POST",
@@ -18,7 +18,7 @@ import { FeishuApplicationConnectionService } from "../../src/connections/feishu
const execFileAsync = promisify(execFile);
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 baseDir: string;
let hubDir: string;
@@ -224,15 +224,21 @@ describe("deployment preflight CLI", () => {
[
"NODE_ENV=production",
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
"FEISHU_APP_ID=cli_app_id",
"FEISHU_APP_SECRET=feishu-app-secret",
"FEISHU_BOT_OPEN_ID=ou_bot",
"HUB_SILO_ORGANIZATION_ID=org_test",
"HUB_SYSTEMD_UNIT=cph-hub-test.service",
`CPH_BIN=${cphBin}`,
"HOST=127.0.0.1",
"PORT=8788",
`HUB_PROJECT_WORKSPACE_ROOT=${options.workspaceRoot}`,
"HUB_PUBLIC_BASE_URL=https://hub.example.com",
"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"),
{ mode: 0o600 },
@@ -264,6 +270,8 @@ describe("deployment preflight CLI", () => {
join(persistentDir, "cache"),
"--workspace-root",
options.workspaceRoot,
"--service-unit",
"cph-hub-test.service",
"--bwrap-bin",
bwrapBin,
"--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> {
const tables = [
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
"OrganizationFeishuApplicationConnection",
"ProviderCredentialVersion",
+40 -7
View File
@@ -4,15 +4,15 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 = [
"DATABASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_BOT_OPEN_ID",
"HUB_SILO_ORGANIZATION_ID",
"HUB_PROJECT_WORKSPACE_ROOT",
"HUB_SESSION_SECRET",
"HUB_PUBLIC_BASE_URL",
@@ -20,6 +20,13 @@ const ENV_KEYS = [
"HUB_SECRET_KEYRING_FILE",
"HOST",
"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;
describe("Hub startup", () => {
@@ -34,6 +41,27 @@ describe("Hub startup", () => {
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
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();
blocker.listen(0, "127.0.0.1");
await once(blocker, "listening");
@@ -50,9 +78,7 @@ describe("Hub startup", () => {
Object.assign(process.env, {
DATABASE_URL: TEST_DATABASE_URL,
FEISHU_APP_ID: "test-app",
FEISHU_APP_SECRET: "test-secret",
FEISHU_BOT_OPEN_ID: "ou_test_bot",
HUB_SILO_ORGANIZATION_ID: DEFAULT_ORG_ID,
HUB_PROJECT_WORKSPACE_ROOT: "/tmp/cph-hub-server-start-test",
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
@@ -60,6 +86,13 @@ describe("Hub startup", () => {
HUB_SECRET_KEYRING_FILE: keyringFile,
HOST: "127.0.0.1",
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 {
@@ -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> {
return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps });
return makeProductionTriggerHandler({
projectWorkspaceRoot: "/tmp",
allowLegacyFeishuIdentity: true,
...deps,
});
}
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
@@ -47,7 +51,7 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return models;
},
async runPolicy() {
return { maxTurns: 7 };
return { maxTurns: 7, maxConcurrentRuns: 4, maxRunSeconds: 300 };
},
};
}
+12 -5
View File
@@ -8,15 +8,21 @@ import {
const VALID_ENV = {
NODE_ENV: "production",
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
FEISHU_APP_ID: "cli_app_id",
FEISHU_APP_SECRET: "feishu-app-secret",
FEISHU_BOT_OPEN_ID: "ou_bot",
HUB_SILO_ORGANIZATION_ID: "org_test",
HUB_SYSTEMD_UNIT: "cph-hub-test.service",
CPH_BIN: "/srv/curriculum-project-hub/current/bin/cph",
HOST: "127.0.0.1",
PORT: "8788",
HUB_PROJECT_WORKSPACE_ROOT: "/var/lib/cph-hub/workspaces",
HUB_PUBLIC_BASE_URL: "https://hub.example.com",
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;
function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPreflightInput {
@@ -27,6 +33,7 @@ function input(overrides: Partial<DeploymentPreflightInput> = {}): DeploymentPre
stateDir: "/var/lib/cph-hub/state",
cacheDir: "/var/cache/cph-hub",
workspaceRoot: "/var/lib/cph-hub/workspaces",
expectedServiceUnit: "cph-hub-test.service",
env: VALID_ENV,
...overrides,
};
@@ -79,7 +86,7 @@ describe("validateDeploymentPreflight", () => {
input({
env: {
...VALID_ENV,
FEISHU_APP_SECRET: "",
HUB_SILO_ORGANIZATION_ID: "",
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
HUB_SESSION_SECRET: "short",
},
@@ -89,7 +96,7 @@ describe("validateDeploymentPreflight", () => {
expect(error).toBeInstanceOf(DeploymentPreflightError);
expect((error as Error).message).toMatchInlineSnapshot(`
"deployment preflight failed:
- FEISHU_APP_SECRET is required
- HUB_SILO_ORGANIZATION_ID is required
- HUB_PUBLIC_BASE_URL must use https
- HUB_SESSION_SECRET must contain at least 32 characters"
`);
+4 -2
View File
@@ -14,8 +14,10 @@ const larkMock = vi.hoisted(() => {
}
}
class MockWSClient {
constructor(private readonly params: { readonly onReady?: () => void }) {}
start(params: { readonly eventDispatcher?: unknown }): void {
starts.push(params);
this.params.onReady?.();
}
}
return { starts, MockEventDispatcher, MockWSClient };
@@ -40,7 +42,7 @@ describe("Feishu card action callbacks", () => {
const onCardAction = vi.fn(async () => action.promise);
const logger = silentLogger();
startFeishuListenerWithClient(
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
@@ -63,7 +65,7 @@ describe("Feishu card action callbacks", () => {
const logger = silentLogger();
const err = new Error("handler failed");
startFeishuListenerWithClient(
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
+21 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
exchangeCodeForUser,
FeishuOAuthError,
type FeishuOAuthConfig,
} from "../../src/admin/auth/feishuOAuth.js";
@@ -75,13 +76,18 @@ describe("exchangeCodeForUser", () => {
it("throws on token exchange failure", async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), {
new Response(JSON.stringify({
code: 20003,
error: "invalid_grant",
error_description: "echoed app secret s and authorization code bad",
}), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
exchangeCodeForUser(
let failure: unknown;
try {
await exchangeCodeForUser(
{
appId: "cli",
appSecret: "s",
@@ -90,7 +96,17 @@ describe("exchangeCodeForUser", () => {
fetchImpl: fetchImpl as unknown as typeof fetch,
},
"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;
},
async runPolicy() {
return { maxTurns: 1 };
return { maxTurns: 1, maxConcurrentRuns: 1, maxRunSeconds: 300 };
},
};
}
@@ -270,6 +270,7 @@ function mockPrisma(): PrismaClient {
update: vi.fn(async () => session),
},
agentRun: {
count: vi.fn(async () => 0),
create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
@@ -292,6 +293,7 @@ function mockPrisma(): PrismaClient {
Object.assign(client, {
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
$executeRaw: vi.fn(async () => 1),
});
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 { join } from "node:path";
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[] = [];
@@ -30,6 +31,20 @@ describe("Feishu resource staging recovery", () => {
/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> {
+7 -1
View File
@@ -30,8 +30,14 @@ describe("runtime settings", () => {
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
const settings = createEnvRuntimeSettings({
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);
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", () => {
@@ -62,6 +79,22 @@ describe("oauth state signing", () => {
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", () => {
+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" });
});
});