diff --git a/.gitea/workflows/hub-check.yml b/.gitea/workflows/hub-check.yml index 0ef40ca..fd5b622 100644 --- a/.gitea/workflows/hub-check.yml +++ b/.gitea/workflows/hub-check.yml @@ -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) diff --git a/AGENTS.md b/AGENTS.md index 9a1f435..e66e942 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)。 ## 纪律 diff --git a/docs/adr/0025-alpha-silo-deployment.md b/docs/adr/0025-alpha-silo-deployment.md new file mode 100644 index 0000000..deb27c4 --- /dev/null +++ b/docs/adr/0025-alpha-silo-deployment.md @@ -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. diff --git a/hub/.env.example b/hub/.env.example index 001139c..3df1b33 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -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. diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 912d823..16fcf2d 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -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//hub \ + INSTANCE_ID=org-a \ + PORT=8788 \ + MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \ + bash /srv/curriculum-project-hub/releases//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//hub/node_modules/prisma/build/index.js \ + migrate deploy \ + --schema /srv/curriculum-project-hub/releases//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//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. diff --git a/hub/deploy/backup_silo.sh b/hub/deploy/backup_silo.sh new file mode 100755 index 0000000..66d3847 --- /dev/null +++ b/hub/deploy/backup_silo.sh @@ -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" diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service index 96a7c79..eda8bac 100644 --- a/hub/deploy/cph-hub.service +++ b/hub/deploy/cph-hub.service @@ -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 diff --git a/hub/deploy/deploy_platform.sh b/hub/deploy/deploy_platform.sh index 86ba95f..cc5834d 100755 --- a/hub/deploy/deploy_platform.sh +++ b/hub/deploy/deploy_platform.sh @@ -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-.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) -ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'" +release_ready=false +if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then + release_ready=true +elif ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -e '$RELEASE_DIR'"; then + echo "incomplete release already exists: $RELEASE_DIR" >&2 + exit 1 +else + ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'" +fi -# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host). -rsync -az --delete \ - --exclude node_modules --exclude dist --exclude .env \ - -e "ssh ${SSH_OPTS[*]}" \ - "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" +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 diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 142bc4d..0defa64 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -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" diff --git a/hub/package-lock.json b/hub/package-lock.json index f5f8104..00f685c 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -24,7 +24,7 @@ "vitest": "^4.1.10" }, "engines": { - "node": ">=20" + "node": ">=24" } }, "node_modules/@ai-sdk/gateway": { diff --git a/hub/package.json b/hub/package.json index bdaf067..08c4eee 100644 --- a/hub/package.json +++ b/hub/package.json @@ -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" diff --git a/hub/prisma/migrations/20260710231000_feishu_user_identity_namespace/migration.sql b/hub/prisma/migrations/20260710231000_feishu_user_identity_namespace/migration.sql new file mode 100644 index 0000000..8c051b4 --- /dev/null +++ b/hub/prisma/migrations/20260710231000_feishu_user_identity_namespace/migration.sql @@ -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; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 70db83a..632a55d 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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 diff --git a/hub/src/admin/auth/feishuOAuth.ts b/hub/src/admin/auth/feishuOAuth.ts index 83cfdfe..aeb371d 100644 --- a/hub/src/admin/auth/feishuOAuth.ts +++ b/hub/src/admin/auth/feishuOAuth.ts @@ -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,66 +74,118 @@ export async function exchangeCodeForUser( async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise { const fetchImpl = config.fetchImpl ?? fetch; const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL; - const res = await fetchImpl(tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: config.appId, - client_secret: config.appSecret, - code, - redirect_uri: config.redirectUri, - }), - }); - const body = (await res.json()) as { - code?: number; - access_token?: string; - error?: string; - error_description?: string; - msg?: string; - }; - if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") { - const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`; - throw new Error(`Feishu OAuth token exchange failed: ${detail}`); + let res: Response; + try { + res = await fetchImpl(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: config.appId, + client_secret: config.appSecret, + code, + redirect_uri: config.redirectUri, + }), + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + } 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 { const fetchImpl = config.fetchImpl ?? fetch; const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL; - const res = await fetchImpl(userInfoUrl, { - method: "GET", - headers: { Authorization: `Bearer ${userAccessToken}` }, - }); - const body = (await res.json()) as { - code?: number; - msg?: string; - data?: { - open_id?: string; - name?: string; - en_name?: string; - avatar_url?: string; - avatar_middle?: string; - }; - }; - if (!res.ok || body.code !== 0 || body.data === undefined) { - throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`); + let res: Response; + try { + res = await fetchImpl(userInfoUrl, { + method: "GET", + headers: { Authorization: `Bearer ${userAccessToken}` }, + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + } 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> { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/hub/src/admin/auth/guards.ts b/hub/src/admin/auth/guards.ts index 0557571..3917561 100644 --- a/hub/src/admin/auth/guards.ts +++ b/hub/src/admin/auth/guards.ts @@ -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: { diff --git a/hub/src/admin/auth/session.ts b/hub/src/admin/auth/session.ts index a248511..105bde5 100644 --- a/hub/src/admin/auth/session.ts +++ b/hub/src/admin/auth/session.ts @@ -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, + 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(token, secret); + const payload = verifyJson>(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, 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 { diff --git a/hub/src/admin/plugin.ts b/hub/src/admin/plugin.ts index 96e5d01..5115942 100644 --- a/hub/src/admin/plugin.ts +++ b/hub/src/admin/plugin.ts @@ -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, { diff --git a/hub/src/admin/routes/authRoutes.ts b/hub/src/admin/routes/authRoutes.ts index c9db9ad..6aff11e 100644 --- a/hub/src/admin/routes/authRoutes.ts +++ b/hub/src/admin/routes/authRoutes.ts @@ -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 { 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,25 +53,61 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), }; - app.get("/auth/feishu", async (request, reply) => { + if (config.allowLegacyFeishuOAuth === true) { + app.get("/auth/feishu", async (request, reply) => { + try { + const returnTo = sanitizeReturnTo( + typeof request.query === "object" && request.query !== null && "returnTo" in request.query + ? String((request.query as { returnTo?: string }).returnTo ?? "") + : "", + ); + const nonce = randomBytes(16).toString("hex"); + const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret); + 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 }, config.sessionSecret); - // state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit. - reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, { - path: "/", - httpOnly: true, - sameSite: "lax", - secure: config.cookieSecure, - maxAge: 600, - }); - const url = buildAuthorizeUrl(oauthConfig, stateToken); - return reply.redirect(url); + 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); } @@ -96,33 +139,93 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute } reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" }); - const feishuUser = await exchangeCodeForUser(oauthConfig, code); - const user = await config.prisma.user.upsert({ - where: { feishuOpenId: feishuUser.openId }, - create: { - feishuOpenId: feishuUser.openId, - displayName: feishuUser.displayName, - avatarUrl: feishuUser.avatarUrl, - }, - update: { - displayName: feishuUser.displayName, - avatarUrl: feishuUser.avatarUrl, - }, - }); + 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 } : {}), + }; + } - setSessionCookie(reply, config, { - userId: user.id, - feishuOpenId: user.feishuOpenId, - }); + 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: { + feishuOpenId: feishuUser.openId, + displayName: feishuUser.displayName, + avatarUrl: feishuUser.avatarUrl, + }, + update: { + displayName: feishuUser.displayName, + avatarUrl: feishuUser.avatarUrl, + }, + }); + userId = user.id; + setSessionCookie(reply, config, { + 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 !== "" ? `

Login failed: ${escapeHtml(q.error)}

` @@ -199,7 +312,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute

Org Admin

Sign in with Feishu to manage your organization.

${errorHtml} - Login with Feishu + ${loginHref === null + ? "

请从组织专属登录链接进入。

" + : `Login with Feishu`} `; @@ -210,7 +325,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute export function setSessionCookie( reply: FastifyReply, config: Pick, - 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 { + 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. */ diff --git a/hub/src/admin/routes/explorerRoutes.ts b/hub/src/admin/routes/explorerRoutes.ts index dad15b5..7d28f2d 100644 --- a/hub/src/admin/routes/explorerRoutes.ts +++ b/hub/src/admin/routes/explorerRoutes.ts @@ -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); diff --git a/hub/src/connections/feishuApplicationConnections.ts b/hub/src/connections/feishuApplicationConnections.ts index 4fd57ac..6cd7b6c 100644 --- a/hub/src/connections/feishuApplicationConnections.ts +++ b/hub/src/connections/feishuApplicationConnections.ts @@ -32,7 +32,7 @@ export interface ResolvedFeishuApplication extends Required { +export interface FeishuSecretPayloadV1 extends Required { readonly schemaVersion: 1; } @@ -46,8 +46,8 @@ export class FeishuApplicationConnectionService { async rotateCustomerApplication( input: RotateFeishuApplicationInput, ): Promise { - 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"); } diff --git a/hub/src/deployment/bootstrap-silo-cli.ts b/hub/src/deployment/bootstrap-silo-cli.ts new file mode 100644 index 0000000..9d2e643 --- /dev/null +++ b/hub/src/deployment/bootstrap-silo-cli.ts @@ -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 { + 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 { + 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 { + 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, 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, 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 { + 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; +}); diff --git a/hub/src/deployment/bootstrap-silo.ts b/hub/src/deployment/bootstrap-silo.ts new file mode 100644 index 0000000..8ff6486 --- /dev/null +++ b/hub/src/deployment/bootstrap-silo.ts @@ -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 { + 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, +): Promise { + 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() } : {}), + }; + }), + } + : {}), + }; +} diff --git a/hub/src/deployment/preflight-cli.ts b/hub/src/deployment/preflight-cli.ts index 984fff4..14bd374 100644 --- a/hub/src/deployment/preflight-cli.ts +++ b/hub/src/deployment/preflight-cli.ts @@ -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 { } 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"), diff --git a/hub/src/deployment/preflight.ts b/hub/src/deployment/preflight.ts index 4e8b864..75cc20e 100644 --- a/hub/src/deployment/preflight.ts +++ b/hub/src/deployment/preflight.ts @@ -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-.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; diff --git a/hub/src/deployment/restore-preflight.ts b/hub/src/deployment/restore-preflight.ts new file mode 100644 index 0000000..1e81473 --- /dev/null +++ b/hub/src/deployment/restore-preflight.ts @@ -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 { + 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; +}); diff --git a/hub/src/deployment/rotate-secret-kek.ts b/hub/src/deployment/rotate-secret-kek.ts index 001f498..a1e818e 100644 --- a/hub/src/deployment/rotate-secret-kek.ts +++ b/hub/src/deployment/rotate-secret-kek.ts @@ -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 { if (process.getuid?.() !== 0) { @@ -30,22 +29,31 @@ async function main(): Promise { } async function requireStoppedService(): Promise { + 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-.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"); diff --git a/hub/src/deployment/silo.ts b/hub/src/deployment/silo.ts new file mode 100644 index 0000000..9825b72 --- /dev/null +++ b/hub/src/deployment/silo.ts @@ -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> = 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 { + 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 }; +} diff --git a/hub/src/deployment/siloRateLimit.ts b/hub/src/deployment/siloRateLimit.ts new file mode 100644 index 0000000..9eee173 --- /dev/null +++ b/hub/src/deployment/siloRateLimit.ts @@ -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 }; + } +} diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 902b21a..f758a51 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -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 { 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, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, -): FeishuRuntime { + onTerminalError?: (error: Error) => void, +): Promise { + let state: "STARTING" | "READY" | "FAILED" = "STARTING"; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + const ready = new Promise((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 Promise> = { "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 | undefined; + const startupTimeout = new Promise((_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, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, -): FeishuRuntime { +): Promise { return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction); } diff --git a/hub/src/feishu/identityNamespace.ts b/hub/src/feishu/identityNamespace.ts new file mode 100644 index 0000000..17156d5 --- /dev/null +++ b/hub/src/feishu/identityNamespace.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/hub/src/feishu/resourceStaging.ts b/hub/src/feishu/resourceStaging.ts index 7f52223..5ec1869 100644 --- a/hub/src/feishu/resourceStaging.ts +++ b/hub/src/feishu/resourceStaging.ts @@ -37,8 +37,12 @@ export async function stageMessageResources( messageId: string, requests: readonly MessageResourceStageRequest[], workspaceRoot: string, + limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number }, ): Promise { 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, diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 56e0ff7..40fd477 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -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; readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise; @@ -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 => { + 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 { 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( diff --git a/hub/src/hub.ts b/hub/src/hub.ts index 814045b..d809fcb 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -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 { 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 { }), ); - 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 { }); 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 { } }, 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; } diff --git a/hub/src/org/members.ts b/hub/src/org/members.ts index 3f1aab4..2fd7455 100644 --- a/hub/src/org/members.ts +++ b/hub/src/org/members.ts @@ -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, - displayName: input.displayName?.trim() || openId, - }, - update: { - ...(input.displayName !== undefined && input.displayName.trim() !== "" - ? { displayName: input.displayName.trim() } - : {}), + 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, + }); 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, diff --git a/hub/src/org/teams.ts b/hub/src/org/teams.ts index d92d54c..6684afa 100644 --- a/hub/src/org/teams.ts +++ b/hub/src/org/teams.ts @@ -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) => ({ - userId: row.user.id, - feishuOpenId: row.user.feishuOpenId, - displayName: row.user.displayName, - createdAt: row.createdAt.toISOString(), - })); + return rows.map((row) => { + const identity = row.user.feishuIdentities[0]; + return { + userId: row.user.id, + 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"); } diff --git a/hub/src/permissions/principals.ts b/hub/src/permissions/principals.ts index d392bdc..cfc2f90 100644 --- a/hub/src/permissions/principals.ts +++ b/hub/src/permissions/principals.ts @@ -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 { const resolved = new PrincipalCollector(); - resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user"); - if (actor.chatId !== undefined && actor.chatId !== "") { - resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat"); - } - - const user = await this.prisma.user.findUnique({ - where: { feishuOpenId: actor.feishuOpenId }, - select: { id: true }, + 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({ diff --git a/hub/src/projectOnboarding.ts b/hub/src/projectOnboarding.ts index 4ddcfd2..7b0dc57 100644 --- a/hub/src/projectOnboarding.ts +++ b/hub/src/projectOnboarding.ts @@ -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( diff --git a/hub/src/security/workspaceFiles.ts b/hub/src/security/workspaceFiles.ts index 8d78291..20fcaff 100644 --- a/hub/src/security/workspaceFiles.ts +++ b/hub/src/security/workspaceFiles.ts @@ -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 { 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 { 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); diff --git a/hub/src/server.ts b/hub/src/server.ts index 6ab6e45..c8fb8cb 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -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. diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index c8a410c..4c11330 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -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, + ), }; } } diff --git a/hub/test/integration/admin-auth.test.ts b/hub/test/integration/admin-auth.test.ts index 125f514..e0dabd3 100644 --- a/hub/test/integration/admin-auth.test.ts +++ b/hub/test/integration/admin-auth.test.ts @@ -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]!; +} diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts index 34db461..e24c459 100644 --- a/hub/test/integration/admin-members-teams.test.ts +++ b/hub/test/integration/admin-members-teams.test.ts @@ -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", diff --git a/hub/test/integration/deployment-preflight-cli.test.ts b/hub/test/integration/deployment-preflight-cli.test.ts index 1880c1d..97726aa 100644 --- a/hub/test/integration/deployment-preflight-cli.test.ts +++ b/hub/test/integration/deployment-preflight-cli.test.ts @@ -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", diff --git a/hub/test/integration/feishu-identity-namespace.test.ts b/hub/test/integration/feishu-identity-namespace.test.ts new file mode 100644 index 0000000..e03cf61 --- /dev/null +++ b/hub/test/integration/feishu-identity-namespace.test.ts @@ -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((resolve) => { release = resolve; }); + let locked!: () => void; + const lockedPromise = new Promise((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}`, + }); +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 291a594..a1cd403 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -36,6 +36,7 @@ export const prisma = new PrismaClient({ export async function resetDb(): Promise { const tables = [ "FeishuEventReceipt", + "FeishuUserIdentity", "FeishuApplicationCredentialVersion", "OrganizationFeishuApplicationConnection", "ProviderCredentialVersion", diff --git a/hub/test/integration/server-start.test.ts b/hub/test/integration/server-start.test.ts index 54c1d32..644fdfe 100644 --- a/hub/test/integration/server-start.test.ts +++ b/hub/test/integration/server-start.test.ts @@ -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 { diff --git a/hub/test/integration/silo-bootstrap.test.ts b/hub/test/integration/silo-bootstrap.test.ts new file mode 100644 index 0000000..e8f91d2 --- /dev/null +++ b/hub/test/integration/silo-bootstrap.test.ts @@ -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"); + }); +}); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 9a506ee..2848cfb 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -22,7 +22,11 @@ type TestTriggerDeps = Omit[0], }; function makeTriggerHandler(deps: TestTriggerDeps): ReturnType { - 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 }; }, }; } diff --git a/hub/test/unit/deployment-preflight.test.ts b/hub/test/unit/deployment-preflight.test.ts index d477220..9ec4e98 100644 --- a/hub/test/unit/deployment-preflight.test.ts +++ b/hub/test/unit/deployment-preflight.test.ts @@ -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 { @@ -27,6 +33,7 @@ function input(overrides: Partial = {}): 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" `); diff --git a/hub/test/unit/feishu-card-action-ack.test.ts b/hub/test/unit/feishu-card-action-ack.test.ts index fae2ba7..9d9b6f6 100644 --- a/hub/test/unit/feishu-card-action-ack.test.ts +++ b/hub/test/unit/feishu-card-action-ack.test.ts @@ -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, diff --git a/hub/test/unit/feishu-oauth.test.ts b/hub/test/unit/feishu-oauth.test.ts index c269165..94887b2 100644 --- a/hub/test/unit/feishu-oauth.test.ts +++ b/hub/test/unit/feishu-oauth.test.ts @@ -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"); }); }); diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index e195d13..d5ff71f 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -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) => callback(client)), $queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]), + $executeRaw: vi.fn(async () => 1), }); return client; } diff --git a/hub/test/unit/resource-staging.test.ts b/hub/test/unit/resource-staging.test.ts index 6b67663..9975e8a 100644 --- a/hub/test/unit/resource-staging.test.ts +++ b/hub/test/unit/resource-staging.test.ts @@ -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 { diff --git a/hub/test/unit/runtime-settings.test.ts b/hub/test/unit/runtime-settings.test.ts index f074b41..a2b7168 100644 --- a/hub/test/unit/runtime-settings.test.ts +++ b/hub/test/unit/runtime-settings.test.ts @@ -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, + }); }); }); diff --git a/hub/test/unit/session.test.ts b/hub/test/unit/session.test.ts index 28ca9d8..9baa0b8 100644 --- a/hub/test/unit/session.test.ts +++ b/hub/test/unit/session.test.ts @@ -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", () => { diff --git a/hub/test/unit/silo-rate-limit.test.ts b/hub/test/unit/silo-rate-limit.test.ts new file mode 100644 index 0000000..98dfb69 --- /dev/null +++ b/hub/test/unit/silo-rate-limit.test.ts @@ -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 }); + }); +}); diff --git a/hub/test/unit/workspace-file-limits.test.ts b/hub/test/unit/workspace-file-limits.test.ts new file mode 100644 index 0000000..917d915 --- /dev/null +++ b/hub/test/unit/workspace-file-limits.test.ts @@ -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" }); + }); +});