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