mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
refactor(server): move OneTalk summaries to internal network
This commit is contained in:
@@ -18,8 +18,6 @@ MIND_AUTH_BASE_URL=https://mind.example.com
|
|||||||
MIND_PAGE_ORIGIN=https://mind.example.com
|
MIND_PAGE_ORIGIN=https://mind.example.com
|
||||||
ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
||||||
MIND_AUTH_TIMEOUT_MS=3000
|
MIND_AUTH_TIMEOUT_MS=3000
|
||||||
# Mind 与 Center 共用的后台纪要历史只读凭据;缺失时后台读取返回 authorization_unavailable。
|
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN=
|
|
||||||
|
|
||||||
# mind-test-harness 的独立联调页目标。该包只供顺道手工测试,不参与根 dev、质量门禁、build 或生产镜像。
|
# mind-test-harness 的独立联调页目标。该包只供顺道手工测试,不参与根 dev、质量门禁、build 或生产镜像。
|
||||||
MIND_TEST_HARNESS_HOST=127.0.0.1
|
MIND_TEST_HARNESS_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ jobs:
|
|||||||
MIND_PAGE_ORIGIN: http://127.0.0.1:7878
|
MIND_PAGE_ORIGIN: http://127.0.0.1:7878
|
||||||
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
||||||
MIND_AUTH_TIMEOUT_MS: "3000"
|
MIND_AUTH_TIMEOUT_MS: "3000"
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: summary-read-token-for-ci-validation-only
|
|
||||||
OSS_BUCKET: sinanpilot-bucket
|
OSS_BUCKET: sinanpilot-bucket
|
||||||
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
|
||||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||||
@@ -104,9 +103,6 @@ jobs:
|
|||||||
pluginOrigins: [process.env.ONETALK_PLUGIN_ORIGINS],
|
pluginOrigins: [process.env.ONETALK_PLUGIN_ORIGINS],
|
||||||
timeoutMs: Number(process.env.MIND_AUTH_TIMEOUT_MS),
|
timeoutMs: Number(process.env.MIND_AUTH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
assert.deepEqual(config.summaryReadAuthorization, {
|
|
||||||
token: process.env.TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN,
|
|
||||||
});
|
|
||||||
console.log("Mind authorization environment is active in CI");
|
console.log("Mind authorization environment is active in CI");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
@@ -241,7 +237,8 @@ jobs:
|
|||||||
MIND_PAGE_ORIGIN: ${{ vars.MIND_PAGE_ORIGIN }}
|
MIND_PAGE_ORIGIN: ${{ vars.MIND_PAGE_ORIGIN }}
|
||||||
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
|
||||||
MIND_AUTH_TIMEOUT_MS: 3000
|
MIND_AUTH_TIMEOUT_MS: 3000
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: ${{ secrets.TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN }}
|
PUBLIC_NETWORK: trade-message-center-public
|
||||||
|
SUMMARY_NETWORK: trade-message-center-summary
|
||||||
|
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
SOURCE_TAG: ${{ github.ref_name }}
|
SOURCE_TAG: ${{ github.ref_name }}
|
||||||
@@ -292,7 +289,8 @@ jobs:
|
|||||||
: "${MIND_PAGE_ORIGIN:?MIND_PAGE_ORIGIN is required}"
|
: "${MIND_PAGE_ORIGIN:?MIND_PAGE_ORIGIN is required}"
|
||||||
: "${ONETALK_PLUGIN_ORIGINS:?ONETALK_PLUGIN_ORIGINS is required}"
|
: "${ONETALK_PLUGIN_ORIGINS:?ONETALK_PLUGIN_ORIGINS is required}"
|
||||||
: "${MIND_AUTH_TIMEOUT_MS:?MIND_AUTH_TIMEOUT_MS is required}"
|
: "${MIND_AUTH_TIMEOUT_MS:?MIND_AUTH_TIMEOUT_MS is required}"
|
||||||
: "${TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN:?TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN is required}"
|
: "${PUBLIC_NETWORK:?PUBLIC_NETWORK is required}"
|
||||||
|
: "${SUMMARY_NETWORK:?SUMMARY_NETWORK is required}"
|
||||||
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
|
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
|
||||||
: "${DEPLOY_USER:?DEPLOY_USER is required}"
|
: "${DEPLOY_USER:?DEPLOY_USER is required}"
|
||||||
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
|
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
|
||||||
@@ -319,7 +317,6 @@ jobs:
|
|||||||
database_file="$build_state_path/database_url"
|
database_file="$build_state_path/database_url"
|
||||||
oss_access_key_id_file="$build_state_path/oss_access_key_id"
|
oss_access_key_id_file="$build_state_path/oss_access_key_id"
|
||||||
oss_access_key_secret_file="$build_state_path/oss_access_key_secret"
|
oss_access_key_secret_file="$build_state_path/oss_access_key_secret"
|
||||||
summary_read_token_file="$build_state_path/summary_read_token"
|
|
||||||
runtime_env_file="$build_state_path/runtime.env"
|
runtime_env_file="$build_state_path/runtime.env"
|
||||||
target_key_file="$build_state_path/target_key"
|
target_key_file="$build_state_path/target_key"
|
||||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||||
@@ -328,12 +325,10 @@ jobs:
|
|||||||
"umask 077; cat > $(quote_for_shell "$oss_access_key_id_file")" <<< "$OSS_ACCESS_KEY_ID"
|
"umask 077; cat > $(quote_for_shell "$oss_access_key_id_file")" <<< "$OSS_ACCESS_KEY_ID"
|
||||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||||
"umask 077; cat > $(quote_for_shell "$oss_access_key_secret_file")" <<< "$OSS_ACCESS_KEY_SECRET"
|
"umask 077; cat > $(quote_for_shell "$oss_access_key_secret_file")" <<< "$OSS_ACCESS_KEY_SECRET"
|
||||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
|
||||||
"umask 077; cat > $(quote_for_shell "$summary_read_token_file")" <<< "$TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN"
|
|
||||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||||
"umask 077; cat > $(quote_for_shell "$target_key_file")" <<< "$DEPLOY_SSH_KEY"
|
"umask 077; cat > $(quote_for_shell "$target_key_file")" <<< "$DEPLOY_SSH_KEY"
|
||||||
|
|
||||||
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") PACKAGE_VERSION=$(quote_for_shell "$PACKAGE_VERSION") OSS_BUCKET=$(quote_for_shell "$OSS_BUCKET") OSS_ENDPOINT=$(quote_for_shell "$OSS_ENDPOINT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") OSS_ACCESS_KEY_ID_FILE=$(quote_for_shell "$oss_access_key_id_file") OSS_ACCESS_KEY_SECRET_FILE=$(quote_for_shell "$oss_access_key_secret_file") SUMMARY_READ_TOKEN_FILE=$(quote_for_shell "$summary_read_token_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
|
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") PACKAGE_VERSION=$(quote_for_shell "$PACKAGE_VERSION") OSS_BUCKET=$(quote_for_shell "$OSS_BUCKET") OSS_ENDPOINT=$(quote_for_shell "$OSS_ENDPOINT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") PUBLIC_NETWORK=$(quote_for_shell "$PUBLIC_NETWORK") SUMMARY_NETWORK=$(quote_for_shell "$SUMMARY_NETWORK") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") OSS_ACCESS_KEY_ID_FILE=$(quote_for_shell "$oss_access_key_id_file") OSS_ACCESS_KEY_SECRET_FILE=$(quote_for_shell "$oss_access_key_secret_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
|
||||||
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
|
||||||
"$build_env bash -s" <<'BUILD_SCRIPT'
|
"$build_env bash -s" <<'BUILD_SCRIPT'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -352,7 +347,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$SUMMARY_READ_TOKEN_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
|
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -375,7 +370,6 @@ jobs:
|
|||||||
database_url="$(<"$DATABASE_FILE")"
|
database_url="$(<"$DATABASE_FILE")"
|
||||||
oss_access_key_id="$(<"$OSS_ACCESS_KEY_ID_FILE")"
|
oss_access_key_id="$(<"$OSS_ACCESS_KEY_ID_FILE")"
|
||||||
oss_access_key_secret="$(<"$OSS_ACCESS_KEY_SECRET_FILE")"
|
oss_access_key_secret="$(<"$OSS_ACCESS_KEY_SECRET_FILE")"
|
||||||
summary_read_token="$(<"$SUMMARY_READ_TOKEN_FILE")"
|
|
||||||
{
|
{
|
||||||
write_env_value NODE_ENV production
|
write_env_value NODE_ENV production
|
||||||
write_env_value HOST "$HOST"
|
write_env_value HOST "$HOST"
|
||||||
@@ -390,7 +384,6 @@ jobs:
|
|||||||
write_env_value MIND_PAGE_ORIGIN "$MIND_PAGE_ORIGIN"
|
write_env_value MIND_PAGE_ORIGIN "$MIND_PAGE_ORIGIN"
|
||||||
write_env_value ONETALK_PLUGIN_ORIGINS "$ONETALK_PLUGIN_ORIGINS"
|
write_env_value ONETALK_PLUGIN_ORIGINS "$ONETALK_PLUGIN_ORIGINS"
|
||||||
write_env_value MIND_AUTH_TIMEOUT_MS "$MIND_AUTH_TIMEOUT_MS"
|
write_env_value MIND_AUTH_TIMEOUT_MS "$MIND_AUTH_TIMEOUT_MS"
|
||||||
write_env_value TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN "$summary_read_token"
|
|
||||||
} > "$RUNTIME_ENV_FILE"
|
} > "$RUNTIME_ENV_FILE"
|
||||||
chmod 600 "$RUNTIME_ENV_FILE"
|
chmod 600 "$RUNTIME_ENV_FILE"
|
||||||
|
|
||||||
@@ -412,13 +405,15 @@ jobs:
|
|||||||
"umask 077; mkdir -p $(quote_for_shell "$DEPLOY_PATH"); cat > $(quote_for_shell "$target_next_env_file"); chmod 600 $(quote_for_shell "$target_next_env_file")" \
|
"umask 077; mkdir -p $(quote_for_shell "$DEPLOY_PATH"); cat > $(quote_for_shell "$target_next_env_file"); chmod 600 $(quote_for_shell "$target_next_env_file")" \
|
||||||
< "$RUNTIME_ENV_FILE"
|
< "$RUNTIME_ENV_FILE"
|
||||||
|
|
||||||
target_env="IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") CURRENT_ENV_FILE=$(quote_for_shell "$target_env_file") NEXT_ENV_FILE=$(quote_for_shell "$target_next_env_file") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") CONTAINER_PORT=$(quote_for_shell "$PORT")"
|
target_env="IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") CURRENT_ENV_FILE=$(quote_for_shell "$target_env_file") NEXT_ENV_FILE=$(quote_for_shell "$target_next_env_file") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") CONTAINER_PORT=$(quote_for_shell "$PORT") PUBLIC_NETWORK=$(quote_for_shell "$PUBLIC_NETWORK") SUMMARY_NETWORK=$(quote_for_shell "$SUMMARY_NETWORK")"
|
||||||
ssh "${target_ssh_options[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
ssh "${target_ssh_options[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
"$target_env bash -s" <<'TARGET_SCRIPT'
|
"$target_env bash -s" <<'TARGET_SCRIPT'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
image="$IMAGE_NAME:$IMAGE_TAG"
|
image="$IMAGE_NAME:$IMAGE_TAG"
|
||||||
env_file="$CURRENT_ENV_FILE"
|
env_file="$CURRENT_ENV_FILE"
|
||||||
next_env_file="$NEXT_ENV_FILE"
|
next_env_file="$NEXT_ENV_FILE"
|
||||||
|
public_network="$PUBLIC_NETWORK"
|
||||||
|
summary_network="$SUMMARY_NETWORK"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$next_env_file"
|
rm -f "$next_env_file"
|
||||||
}
|
}
|
||||||
@@ -426,6 +421,49 @@ jobs:
|
|||||||
|
|
||||||
mkdir -p "$DEPLOY_PATH"
|
mkdir -p "$DEPLOY_PATH"
|
||||||
test -s "$next_env_file"
|
test -s "$next_env_file"
|
||||||
|
ensure_public_network() {
|
||||||
|
if docker network inspect "$public_network" >/dev/null 2>&1; then
|
||||||
|
[[ "$(docker network inspect --format '{{.Internal}}' "$public_network")" == "false" ]] || {
|
||||||
|
echo "Docker network $public_network must allow egress" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
docker network create "$public_network" >/dev/null
|
||||||
|
}
|
||||||
|
ensure_summary_network() {
|
||||||
|
if docker network inspect "$summary_network" >/dev/null 2>&1; then
|
||||||
|
[[ "$(docker network inspect --format '{{.Internal}}' "$summary_network")" == "true" ]] || {
|
||||||
|
echo "Docker network $summary_network must be internal" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
docker network create --internal "$summary_network" >/dev/null
|
||||||
|
}
|
||||||
|
start_container() {
|
||||||
|
local container_image="$1"
|
||||||
|
local container_env_file="$2"
|
||||||
|
if [[ -n "$container_env_file" ]]; then
|
||||||
|
docker run -d \
|
||||||
|
--name "$DEPLOY_CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--env-file "$container_env_file" \
|
||||||
|
--network "$public_network" \
|
||||||
|
--publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \
|
||||||
|
"$container_image"
|
||||||
|
else
|
||||||
|
docker run -d \
|
||||||
|
--name "$DEPLOY_CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$public_network" \
|
||||||
|
--publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \
|
||||||
|
"$container_image"
|
||||||
|
fi
|
||||||
|
docker network connect "$summary_network" "$DEPLOY_CONTAINER_NAME"
|
||||||
|
}
|
||||||
|
ensure_public_network
|
||||||
|
ensure_summary_network
|
||||||
previous_image="$(docker inspect --format '{{.Config.Image}}' "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true)"
|
previous_image="$(docker inspect --format '{{.Config.Image}}' "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true)"
|
||||||
restore_previous() {
|
restore_previous() {
|
||||||
docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true
|
docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true
|
||||||
@@ -433,20 +471,11 @@ jobs:
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if [[ -s "$env_file" ]]; then
|
if [[ -s "$env_file" ]]; then
|
||||||
docker run -d \
|
start_container "$previous_image" "$env_file"
|
||||||
--name "$DEPLOY_CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--env-file "$env_file" \
|
|
||||||
--publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \
|
|
||||||
"$previous_image"
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
echo "Restoring legacy image without an env-file: $previous_image" >&2
|
echo "Restoring legacy image without an env-file: $previous_image" >&2
|
||||||
docker run -d \
|
start_container "$previous_image" ""
|
||||||
--name "$DEPLOY_CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \
|
|
||||||
"$previous_image"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
migration_container="${DEPLOY_CONTAINER_NAME}-migration"
|
migration_container="${DEPLOY_CONTAINER_NAME}-migration"
|
||||||
@@ -457,12 +486,7 @@ jobs:
|
|||||||
"$image" \
|
"$image" \
|
||||||
node dist/src/database/migrate.js
|
node dist/src/database/migrate.js
|
||||||
docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true
|
docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true
|
||||||
if ! docker run -d \
|
if ! start_container "$image" "$next_env_file"; then
|
||||||
--name "$DEPLOY_CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--env-file "$next_env_file" \
|
|
||||||
--publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \
|
|
||||||
"$image"; then
|
|
||||||
restore_previous
|
restore_previous
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ apps/
|
|||||||
│ ├── app.ts
|
│ ├── app.ts
|
||||||
│ ├── config.ts
|
│ ├── config.ts
|
||||||
│ ├── entry.ts
|
│ ├── entry.ts
|
||||||
|
│ ├── runtime.ts
|
||||||
│ ├── database/
|
│ ├── database/
|
||||||
│ │ ├── index.ts
|
│ │ ├── index.ts
|
||||||
│ │ ├── migration-config.ts
|
│ │ ├── migration-config.ts
|
||||||
@@ -78,10 +79,11 @@ OneTalk 业务服务、Bright 读取路由、独立历史 cursor 和 repository
|
|||||||
|
|
||||||
## 新增模块时
|
## 新增模块时
|
||||||
|
|
||||||
- Fastify 实例由 `src/app.ts` 创建,`src/entry.ts` 是唯一监听入口;路由和外部适配分别归 `http/`、`websocket/`、`database/`。
|
- public Fastify 实例由 `src/app.ts` 创建;`src/runtime.ts` 组合 public 与 internal summary listener,`src/entry.ts` 是唯一调用 runtime 监听/关闭的进程入口。路由和外部适配分别归 `http/`、`websocket/`、`database/`。
|
||||||
- OneTalk 领域规则归 `src/onetalk/`,其中 `model.ts` 定义 port,`service.ts` 编排消息/锚点,`profile-service.ts`/`profile-repository.ts` 负责资料当前事实,`read-service.ts`/`read-repository.ts` 负责唯一公开读取投影;WebSocket handler 不直接访问数据库。
|
- OneTalk 领域规则归 `src/onetalk/`,其中 `model.ts` 定义 port,`service.ts` 编排消息/锚点,`profile-service.ts`/`profile-repository.ts` 负责资料当前事实,`read-service.ts`/`read-repository.ts` 负责唯一公开读取投影;WebSocket handler 不直接访问数据库。
|
||||||
- `websocket/registry.ts` 只管理已认证连接和 publish-after-commit 的 Mind 发布,不创建消息事实或 outbox。
|
- `websocket/registry.ts` 只管理已认证连接和 publish-after-commit 的 Mind 发布,不创建消息事实或 outbox。
|
||||||
- `http/onetalk.ts` 只做 Bright scope/授权/参数边界和响应映射;列表/详情/历史查询委派给 `read-service.ts`,不直接访问 Drizzle。
|
- `http/onetalk.ts` 只做 Bright scope/授权/参数边界和响应映射;列表/详情/历史查询委派给 `read-service.ts`,不直接访问 Drizzle。
|
||||||
|
- `http/onetalk-summary.ts` 只注册 internal `7777` history route,复用 `http/onetalk.ts` 的 history 输入/错误映射与 `read-service.ts`;它不读取授权头、Cookie 或配置,不注册 CORS、WebSocket、列表/详情或 public health。
|
||||||
- `http/harness.ts` 只提供本地原生 HTML 联调页;页面通过 Bright HTTP/WS 访问服务,不复制 repository 或 Mind legacy 读取。
|
- `http/harness.ts` 只提供本地原生 HTML 联调页;页面通过 Bright HTTP/WS 访问服务,不复制 repository 或 Mind legacy 读取。
|
||||||
- `http/chrome-extension-download.ts` 只返回当前 workspace 版本的扩展临时下载链接;对象路径和 V1 签名委托给 `oss/chrome-extension-download.ts`,不代理 ZIP 或记录签名。缺少签名凭据时稳定返回 `503 extension_download_unavailable`。
|
- `http/chrome-extension-download.ts` 只返回当前 workspace 版本的扩展临时下载链接;对象路径和 V1 签名委托给 `oss/chrome-extension-download.ts`,不代理 ZIP 或记录签名。缺少签名凭据时稳定返回 `503 extension_download_unavailable`。
|
||||||
- `src/oss/v1-signed-url.ts` 只拥有无网络副作用的 OSS V1 GET 签名、URL 边界校验和 query 拼接;它不读取 `process.env`、不记录 secret、不增加路由或 OSS 网络调用。调用者未来接入时仍须通过 `src/config.ts` 映射环境变量。
|
- `src/oss/v1-signed-url.ts` 只拥有无网络副作用的 OSS V1 GET 签名、URL 边界校验和 query 拼接;它不读取 `process.env`、不记录 secret、不增加路由或 OSS 网络调用。调用者未来接入时仍须通过 `src/config.ts` 映射环境变量。
|
||||||
@@ -131,7 +133,7 @@ or (sent_at_ms = cursor.sentAtMs and message_id < cursor.messageId)
|
|||||||
- `limit` 默认 `50`,最大 `100`。repository 查询 `limit + 1` 行,服务层只返回前 `limit` 行并据此计算 `hasMore`。
|
- `limit` 默认 `50`,最大 `100`。repository 查询 `limit + 1` 行,服务层只返回前 `limit` 行并据此计算 `hasMore`。
|
||||||
- 首次请求取得最新的一页;API 响应的 `messages` 始终按旧到新排列。带 `nextCursor` 的下一页是更旧的一页,前端必须将其 prepend 到已有消息之前。
|
- 首次请求取得最新的一页;API 响应的 `messages` 始终按旧到新排列。带 `nextCursor` 的下一页是更旧的一页,前端必须将其 prepend 到已有消息之前。
|
||||||
- 历史读取 cursor 是 `read-cursor.ts` 拥有的 opaque payload,绑定账号、会话、时间窗、`asOf`、`sentAtMs` 和 `messageId`。排序方向变化必须提升 cursor 版本;当前倒序分页使用 v2,旧 v1 cursor 返回 `invalid_cursor`,不得静默按新方向解释。
|
- 历史读取 cursor 是 `read-cursor.ts` 拥有的 opaque payload,绑定账号、会话、时间窗、`asOf`、`sentAtMs` 和 `messageId`。排序方向变化必须提升 cursor 版本;当前倒序分页使用 v2,旧 v1 cursor 返回 `invalid_cursor`,不得静默按新方向解释。
|
||||||
- `X-Mind-Purpose: communication_summary_read` 仍要求 `fromSentAtMs` 与 `toSentAtMs` 同时存在;缺失时返回 `400 invalid_time_range`。
|
- internal `7777` summary listener 固定要求 `fromSentAtMs` 与 `toSentAtMs` 同时存在;缺失时返回 `400 invalid_time_range`,public Cookie history 不因请求 header 进入 summary mode。
|
||||||
|
|
||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ or (sent_at_ms = cursor.sentAtMs and message_id < cursor.messageId)
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 普通读取未传两个时间戳 | 接受;无消息时间过滤,按最新页读取 |
|
| 普通读取未传两个时间戳 | 接受;无消息时间过滤,按最新页读取 |
|
||||||
| `fromSentAtMs >= toSentAtMs` | `400 invalid_time_range` |
|
| `fromSentAtMs >= toSentAtMs` | `400 invalid_time_range` |
|
||||||
| summary purpose 缺少任一时间戳 | `400 invalid_time_range` |
|
| internal summary 缺少任一时间戳 | `400 invalid_time_range` |
|
||||||
| cursor 不属于当前账号/会话/时间窗,或不是当前 v2 canonical payload | `400 invalid_cursor` |
|
| cursor 不属于当前账号/会话/时间窗,或不是当前 v2 canonical payload | `400 invalid_cursor` |
|
||||||
| cursor 使用旧 v1 排序语义 | `400 invalid_cursor`;要求重新请求第一页 |
|
| cursor 使用旧 v1 排序语义 | `400 invalid_cursor`;要求重新请求第一页 |
|
||||||
| 数据库读取失败 | `503 database_unavailable` |
|
| 数据库读取失败 | `503 database_unavailable` |
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ handleReadFailure(reply, error) -> database_unavailable | internal_error
|
|||||||
- 认证上下文缺失是 `401 auth_required`;scope 冲突是 `403 scope_mismatch`;授权适配器拒绝是 `403`,不可用是 `503`。
|
- 认证上下文缺失是 `401 auth_required`;scope 冲突是 `403 scope_mismatch`;授权适配器拒绝是 `403`,不可用是 `503`。
|
||||||
- 未知会话是 `404 conversation_not_found`;分页输入错误是 `400 invalid_limit` 或 `400 invalid_cursor`。
|
- 未知会话是 `404 conversation_not_found`;分页输入错误是 `400 invalid_limit` 或 `400 invalid_cursor`。
|
||||||
- `OneTalkDatabaseError` 映射到 `503 database_unavailable`;其它未预期异常只映射到 `500 internal_error`,不吞掉服务端日志/监控路径。
|
- `OneTalkDatabaseError` 映射到 `503 database_unavailable`;其它未预期异常只映射到 `500 internal_error`,不吞掉服务端日志/监控路径。
|
||||||
- `communication_summary_read` 必须同时带 `fromSentAtMs` 与 `toSentAtMs`,且满足 `from < to`;缺失或非法统一映射为 `400 invalid_time_range`。历史不完整时返回 `503 history_incomplete` 和 `Retry-After: 30`。
|
- internal `7777` summary listener 必须同时带 `fromSentAtMs` 与 `toSentAtMs`,且满足 `from < to`;缺失或非法统一映射为 `400 invalid_time_range`。历史不完整时返回 `503 history_incomplete` 和 `Retry-After: 30`;public Cookie route 不由请求 header 切换 summary mode。
|
||||||
- 读取只接受显式 `conversation_kind = "direct"`;列表、详情和历史都不得通过客户端字段推断其它会话类型。
|
- 读取只接受显式 `conversation_kind = "direct"`;列表、详情和历史都不得通过客户端字段推断其它会话类型。
|
||||||
- 错误响应、WS error frame、页面可见状态和日志字段不得包含 credential、Cookie、连接串或完整原始 envelope。
|
- 错误响应、WS error frame、页面可见状态和日志字段不得包含 credential、Cookie、连接串或完整原始 envelope。
|
||||||
|
|
||||||
@@ -142,8 +142,8 @@ handleReadFailure(reply, error) -> database_unavailable | internal_error
|
|||||||
| Authorization unavailable | `503 authorization_unavailable` |
|
| Authorization unavailable | `503 authorization_unavailable` |
|
||||||
| Unknown conversation | `404 conversation_not_found` |
|
| Unknown conversation | `404 conversation_not_found` |
|
||||||
| Invalid cursor or limit | `400 invalid_cursor` / `invalid_limit` |
|
| Invalid cursor or limit | `400 invalid_cursor` / `invalid_limit` |
|
||||||
| Invalid summary time window | `400 invalid_time_range` |
|
| Internal summary time window invalid | `400 invalid_time_range` |
|
||||||
| Summary requested before history is complete | `503 history_incomplete` with `Retry-After: 30` |
|
| Internal summary requested before history is complete | `503 history_incomplete` with `Retry-After: 30` |
|
||||||
| Repository storage failure | `503 database_unavailable` |
|
| Repository storage failure | `503 database_unavailable` |
|
||||||
|
|
||||||
### 5. Good / Base / Bad Cases
|
### 5. Good / Base / Bad Cases
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
| [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 |
|
| [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 |
|
||||||
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
|
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
|
||||||
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
|
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
|
||||||
| [后台纪要专用只读授权](./summary-authorization.md) | 专用 Bearer、Mind 精确会话回调、scope 隔离与发布凭据 | Center 独立契约 |
|
| [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 |
|
||||||
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、CORS/Origin 与 fail-closed 边界 | 已建立适配器与本地 mock |
|
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、CORS/Origin 与 fail-closed 边界 | 已建立适配器与本地 mock |
|
||||||
| [OneTalk 联系人资料 Bright 持久化](./mind-contact-profile.md) | profile composite key、严格时间前进 upsert、future-skew 拒绝、transaction/ACK fence 和 read-model 内存组合 | 已实现并有 focused tests;真实 PostgreSQL 另行验证 |
|
| [OneTalk 联系人资料 Bright 持久化](./mind-contact-profile.md) | profile composite key、严格时间前进 upsert、future-skew 拒绝、transaction/ACK fence 和 read-model 内存组合 | 已实现并有 focused tests;真实 PostgreSQL 另行验证 |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
服务端未引入额外日志库。后台纪要授权和读取使用下文限定的结构化诊断,由生产启动入口实际写入 stderr;其它诊断仍遵循各自注入边界。Fastify 默认 request logger 保持关闭。
|
服务端未引入额外日志库。其它诊断仍遵循各自注入边界,Fastify 默认 request logger 保持关闭。
|
||||||
|
|
||||||
## 当前规则
|
## 当前规则
|
||||||
|
|
||||||
@@ -47,11 +47,3 @@ HTTP `status`, and stable authorization `code`. Each request emits `request_star
|
|||||||
upstream call; transport failures are reported as `request_failed`. The development entry prints
|
upstream call; transport failures are reported as `request_failed`. The development entry prints
|
||||||
these events as `[mind-auth][diagnostic]`. It must not print the upstream URL, Cookie, binding,
|
these events as `[mind-auth][diagnostic]`. It must not print the upstream URL, Cookie, binding,
|
||||||
request body, response body, or raw exception.
|
request body, response body, or raw exception.
|
||||||
|
|
||||||
## OneTalk summary production diagnostics
|
|
||||||
|
|
||||||
后台纪要例外由本次明确的恢复可观测性需求建立,不启用全量 Fastify 请求日志,也不扩大既有 WS/profile 诊断范围。`startServer` 默认注入 `SummaryAuthorizationDiagnosticsSink`,把限定字段序列化为单行 JSON 写入 stderr,由既有进程和容器日志设施承接。测试或自定义启动方可显式替换 sink。
|
|
||||||
|
|
||||||
事件 `onetalk_summary_authorization` 包含 `requestId`、`stage`(credential/callback/read)、`outcome`、稳定 `code` 和 `durationMs`。后台每页请求都可跟踪本地凭据判定、Mind 回调与读取成功、失败或等待;`history_incomplete` 是 waiting,不是成功空页。授权协议见 [后台纪要专用只读授权](./summary-authorization.md)。
|
|
||||||
|
|
||||||
禁止输出 Token、Cookie、scope 标识、请求和响应正文、完整 URL、数据库异常及原始 exception。上游 code 必须来自严格 decoder 的已登记集合。授权失败保持稳定响应,不因日志 sink 故障改变业务结果。新日志没有单独持久化表、网络输出或第三方日志依赖,生产留存沿用运行平台既有设置。测试必须区分 fixture sink 与实际生产入口默认 stderr,两者不能互相冒充验证。
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
loadConfig(environment: Record<string, string | undefined>): ServerConfig
|
loadConfig(environment: Record<string, string | undefined>): ServerConfig
|
||||||
createDatabase(databaseUrl: string): { db: Database; close(): Promise<void> }
|
createDatabase(databaseUrl: string): { db: Database; close(): Promise<void> }
|
||||||
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
|
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
|
||||||
|
createServerRuntime(config: ServerConfig, dependencies?: ServerRuntimeDependencies): ServerRuntime
|
||||||
startServer(): Promise<void>
|
startServer(): Promise<void>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ startServer(): Promise<void>
|
|||||||
- Optional `NODE_ENV` is normalized at the configuration boundary; only exact trimmed `development` enables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。
|
- Optional `NODE_ENV` is normalized at the configuration boundary; only exact trimmed `development` enables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。
|
||||||
- `GET /health` returns `{ "status": "ok" }`。
|
- `GET /health` returns `{ "status": "ok" }`。
|
||||||
- WebSocket routes are `GET /ws/plugin` and `GET /ws/mind`; each route binds its connection type before upgrade and checks its exact Origin allowlist. `GET /ws` is reject-only compatibility behavior (`426 onetalk_protocol_upgrade_required`) and has no legacy handler, outbox or dispatch path. Malformed or unsupported protocol frames close with code `1003`, and connection/handler errors close with `1011`。
|
- WebSocket routes are `GET /ws/plugin` and `GET /ws/mind`; each route binds its connection type before upgrade and checks its exact Origin allowlist. `GET /ws` is reject-only compatibility behavior (`426 onetalk_protocol_upgrade_required`) and has no legacy handler, outbox or dispatch path. Malformed or unsupported protocol frames close with code `1003`, and connection/handler errors close with `1011`。
|
||||||
- `createApp` never calls `listen`; only `entry.ts` may bind the port。
|
- `createApp` never calls `listen`。`entry.ts` 是唯一进程入口,它调用 `runtime.ts` 的双 listener lifecycle;`runtime.ts` 只绑定 public `HOST:PORT` 与 internal `0.0.0.0:7777`,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
|
||||||
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs。
|
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs。
|
||||||
- `createApp` composes one injected/default `OneTalkService`, `OneTalkProfileService` and `OneTalkReadService` over the same `database.db`; `AppDependencies` may inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters。
|
- `createApp` composes one injected/default `OneTalkService`, `OneTalkProfileService` and `OneTalkReadService` over the same `database.db`; `AppDependencies` may inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters。
|
||||||
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created`。`message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
|
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created`。`message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
|
||||||
@@ -234,10 +235,10 @@ GET /harness -> text/html (native browser page)
|
|||||||
- HTTP scope 只取路径 `channelAccountId` 加 Mind Session 授权返回的完整 `mindScope`;不接受客户端 user/workspace/device header 作为主体。每个请求都使用 `OneTalkAuthorizationReader.authorize({ connectionType: "mind_page", operation: "read", scope: { channelAccountId }, cookie? })`。
|
- HTTP scope 只取路径 `channelAccountId` 加 Mind Session 授权返回的完整 `mindScope`;不接受客户端 user/workspace/device header 作为主体。每个请求都使用 `OneTalkAuthorizationReader.authorize({ connectionType: "mind_page", operation: "read", scope: { channelAccountId }, cookie? })`。
|
||||||
- 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation`:`name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0;`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。
|
- 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation`:`name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0;`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。
|
||||||
- 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
|
- 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
|
||||||
- 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。summary purpose `communication_summary_read` 必须同时提供两端时间。
|
- 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。内部 `7777` summary listener 必须同时提供两端时间。
|
||||||
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1` 的 `text | image | file` union。read projection 不得解 Base64、`custom.data`、`contentType`、文件名或 URL fallback。
|
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1` 的 `text | image | file` union。read projection 不得解 Base64、`custom.data`、`contentType`、文件名或 URL fallback。
|
||||||
- `/harness` 只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,按 scope/account/conversation/messageId 去重。它按 `content.kind` 转义渲染文本、以真实 `<img>` 加载图片预览并显示加载失败、为文件保留元数据且只在 URL 存在时提供带 `noopener noreferrer` 的用户触发链接;不解 raw 字段、不自动下载、不增加媒体发送。
|
- `/harness` 只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,按 scope/account/conversation/messageId 去重。它按 `content.kind` 转义渲染文本、以真实 `<img>` 加载图片预览并显示加载失败、为文件保留元数据且只在 URL 存在时提供带 `noopener noreferrer` 的用户触发链接;不解 raw 字段、不自动下载、不增加媒体发送。
|
||||||
- 插件 `plugin.status`、`sync.status` 和 `message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。HTTP CORS 只允许精确 Origin 和 `Content-Type`/`X-Mind-Purpose`。
|
- 插件 `plugin.status`、`sync.status` 和 `message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`;internal summary listener 不注册 CORS。
|
||||||
|
|
||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
@@ -249,9 +250,9 @@ GET /harness -> text/html (native browser page)
|
|||||||
| 授权依赖不可用 | HTTP `503`,`authorization_unavailable` |
|
| 授权依赖不可用 | HTTP `503`,`authorization_unavailable` |
|
||||||
| limit 非整数或不在 `1..100` | HTTP `400`,`invalid_limit` |
|
| limit 非整数或不在 `1..100` | HTTP `400`,`invalid_limit` |
|
||||||
| cursor 无法解码或跨账号/会话使用 | HTTP `400`,`invalid_cursor` |
|
| cursor 无法解码或跨账号/会话使用 | HTTP `400`,`invalid_cursor` |
|
||||||
| summary 缺少 from/to,或 from >= to | HTTP `400`,`invalid_time_range` |
|
| internal summary 缺少 from/to,或 from >= to | HTTP `400`,`invalid_time_range` |
|
||||||
| 会话不存在 | HTTP `404`,`conversation_not_found` |
|
| 会话不存在 | HTTP `404`,`conversation_not_found` |
|
||||||
| history 尚未完整且请求 summary | HTTP `503 history_incomplete`,`Retry-After: 30` |
|
| history 尚未完整且请求 internal summary | HTTP `503 history_incomplete`,`Retry-After: 30` |
|
||||||
| 数据库读取失败 | HTTP `503`,`database_unavailable`;不返回内部异常 |
|
| 数据库读取失败 | HTTP `503`,`database_unavailable`;不返回内部异常 |
|
||||||
| 插件无在线连接 | 仍可读历史;响应/事件状态为 `offline`,实时发送能力保持禁用 |
|
| 插件无在线连接 | 仍可读历史;响应/事件状态为 `offline`,实时发送能力保持禁用 |
|
||||||
|
|
||||||
@@ -263,9 +264,9 @@ GET /harness -> text/html (native browser page)
|
|||||||
|
|
||||||
### 6. Tests Required
|
### 6. Tests Required
|
||||||
|
|
||||||
- HTTP:列表、详情、历史首/后续页、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、summary gate、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;text/image/file 必须只含 normalized content。
|
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;text/image/file 必须只含 normalized content。
|
||||||
- WebSocket:Mind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
|
- WebSocket:Mind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
|
||||||
- Harness:`GET /harness` 的 HTML 标记、Bright HTTP/WS 路径、summary header、offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段。
|
- Harness:`GET /harness` 的 HTML 标记、Bright public HTTP/WS 路径、offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段。
|
||||||
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离。
|
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离。
|
||||||
|
|
||||||
### 7. Wrong vs Correct
|
### 7. Wrong vs Correct
|
||||||
|
|||||||
@@ -1,77 +1,40 @@
|
|||||||
# 后台纪要专用只读授权
|
# 后台纪要内部网络读取
|
||||||
|
|
||||||
## 1. Scope / Trigger
|
## 1. Scope / Trigger
|
||||||
|
|
||||||
Mind 后台纪要任务通过 Bearer 读取 Center 单会话历史时应用本规范。Cookie 页面、plugin binding、WS 和发送仍由现有授权端口处理。Center 不连接 Mind 数据库,不从历史消息的 workspace/user 推导当前归属;Mind 是归属事实源。本契约无需数据库迁移。
|
Mind 后台纪要通过与 Center 同一 Docker daemon 的 `trade-message-center-summary` internal 网络读取单会话历史时应用本规范。Cookie 页面、plugin binding、WS、发送和 public `7878` 仍由现有 Mind HTTP 授权处理。网络成员资格是摘要读取的唯一授权边界;Center 不连接 Mind 数据库,也不推导 workspace/user 归属。
|
||||||
|
|
||||||
## 2. Signatures
|
## 2. Signatures
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages
|
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages
|
||||||
Authorization: Bearer <TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN>
|
|
||||||
X-Mind-Purpose: communication_summary_read
|
|
||||||
X-Mind-Workspace-Id: <workspaceId>
|
|
||||||
Query: fromSentAtMs, toSentAtMs, limit?, cursor?
|
Query: fromSentAtMs, toSentAtMs, limit?, cursor?
|
||||||
|
Response: { conversationId, messages, page }
|
||||||
POST <MIND_AUTH_BASE_URL>/internal/bright/onetalk/authorize-summary
|
|
||||||
Authorization: Bearer <same configured secret>
|
|
||||||
Body: { purpose: "communication_summary_read", workspaceId, channelAccountId, conversationId }
|
|
||||||
200: { purpose: "communication_summary_read", scope: { workspaceId, channelAccountId, conversationId }, permissions: ["read"] }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Contracts
|
## 3. Contracts
|
||||||
|
|
||||||
- 专用 summary scope 的 owner 为 `onetalk-contract/src/summary-authorization.ts`,不含 `mindUserId`、binding 或 authorizationVersion,不扩展通用 OneTalk session/binding 授权语义。
|
- `entry.ts` 通过 runtime 组合 public 和 internal 两个 Fastify listener;internal listener 固定绑定 `0.0.0.0:7777`,只注册 history GET,不注册 WebSocket、CORS、列表、详情或扩展下载路由。
|
||||||
- 只有 messages 可选择 summary 授权。出现 Authorization 或后台 workspace header 后必须按后台分支处理,purpose 不符、凭据失败或回调失败不得落回 Cookie。纯 Cookie 请求保持既有规则,包括 Cookie + summary purpose。
|
- internal listener 直接以 server-local `communication_summary_read` purpose 调用唯一 `OneTalkReadService`。它不读取 Cookie、Bearer、`X-Mind-Purpose` 或 `X-Mind-Workspace-Id`,不回调 Mind,也不返回 scope/workspace/user/binding/authorization version。
|
||||||
- 专用 Token 至少 32 字符,仅接受 ASCII token68 字符,`=` 仅允许尾部;配置与 Bearer 解析复用 `summary-credential.ts` 的规则,使用固定长度 digest 与常量时间比较。未配置时后台请求明确失败,不能制造占位 origin、猜测用户、选择默认 secret 或构造已授权 scope。
|
- summary 必须有完整且有效的 `[fromSentAtMs,toSentAtMs)`;cursor 仍绑定账号、会话、窗口和 snapshot。`historyComplete=false` 返回 `503 history_incomplete` 和 `Retry-After: 30`。
|
||||||
- 回调仅访问配置的固定 Mind origin 和固定 path;限制超时、禁止 redirect,不转发 Cookie,不缓存跨页授权。每页再次授权。
|
- public listener 只走 Cookie/Mind session reader;旧 summary headers 不创建未授权分支。`MIND_AUTH_BASE_URL` 继续只服务页面/plugin 的通用授权。
|
||||||
- 成功响应严格解码字段、purpose 和单一 read 权限,scope 三项必须逐项匹配请求。Mind 根据同 workspace 的精确 Alibaba 单聊、账号和 connection 路由事实核验,并拒绝缺失或歧义。
|
- 发布仅 `--publish 7878:7878`;workflow 让 Center 先加入仅供它自身 egress/public port 的 `trade-message-center-public` bridge network,再附加 `Internal=true` 的 summary network。Center 不得留在 Docker 默认 bridge,否则其默认 bridge peers 可直连 7777。Mind 容器由其所有者附加 summary network,且必须与 Center 在同一 Docker daemon。`EXPOSE` 不提供访问控制。
|
||||||
- 复用既有 history read service/repository;分页保持页内升序、nextCursor 向更旧消息。每页固定相同 from/to,时间范围左闭右开,limit 最大 100,cursor 绑定账号/会话/时间范围与快照。Center 不承担 Mind 的跨页全局排序与首条历史定位。
|
- 网络成员可读取任何已知 account/conversation,Docker socket 持有者或宿主机 root 不受该边界约束。若需要 workspace 或会话级授权,必须另行恢复明确授权机制,不能从 Center 数据猜测。
|
||||||
- 服务授权仅进入 history 输入,不能用于列表/详情、发送或 WS。cutover epoch 在异步授权和读取之后仍复核。
|
|
||||||
- `TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` 通过 GitHub production secret 注入发布生成的 server.env;不能写入仓库、镜像或 SSH 命令参数。传输沿 stdin/受限临时文件链,结束清理,回滚应用与 env 成对执行。
|
|
||||||
- 诊断只保留请求关联、阶段、稳定分类、耗时及结果统计;禁止 Token、Cookie、完整 URL、原始 scope、正文、原始异常和 DB 信息。
|
|
||||||
|
|
||||||
## 4. Validation & Error Matrix
|
## 4. Validation & Error Matrix
|
||||||
|
|
||||||
| 条件 | Center 行为 |
|
| 条件 | Center 行为 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 错误/缺失 Bearer,带后台标记 | 401 auth_required,无 Cookie fallback |
|
| internal 请求缺少 from/to,或 from >= to | 400 invalid_time_range |
|
||||||
| 后台配置缺失 | 503 authorization_unavailable |
|
| cursor 跨账号/会话/窗口 | 400 invalid_cursor |
|
||||||
| purpose 错误或作用域输入不合法 | 明确 4xx 拒绝,不读取消息 |
|
|
||||||
| Mind 401 auth_required | 401 auth_required |
|
|
||||||
| Mind 403 scope_forbidden | 403 scope_mismatch |
|
|
||||||
| Mind 403 summary_workspace_disabled | 403 authorization_rejected,诊断保留已登记的上游分类 |
|
|
||||||
| Mind 400 invalid_request | 403 authorization_rejected |
|
|
||||||
| scope 不匹配、未知字段/错误形状、未知 code/状态组合、非 JSON、5xx、超时或 redirect | 503 authorization_unavailable,不读取消息 |
|
|
||||||
| from/to 缺失、非法或 from >= to | 400 invalid_time_range |
|
|
||||||
| 历史未完整同步 | 503 history_incomplete,Retry-After: 30 |
|
| 历史未完整同步 | 503 history_incomplete,Retry-After: 30 |
|
||||||
| 跨账号/会话/窗口 cursor | 400 invalid_cursor |
|
| cutover 被暂停或 epoch 变化 | 503 authorization_unavailable;它表示可用性 fence,不是凭据检查 |
|
||||||
| 合法完整历史的空窗口 | 成功空消息页,不伪造历史完整性 |
|
| 数据库失败 | 503 database_unavailable |
|
||||||
|
| public 请求携带 retired Bearer/workspace/purpose headers | 仍走 Cookie/Mind page 授权;不会获得 internal summary |
|
||||||
|
|
||||||
## 5. Good / Base / Bad Cases
|
## 5. Tests Required
|
||||||
|
|
||||||
- Good:先验证凭据,再由 Mind 数据库核验 scope,最后以授权结果读取精确会话。
|
- internal route 证明无认证读取、完整窗口、history gate、无 scope 响应、数据库/epoch 错误和 cursor 语义。
|
||||||
- Base:本地 loopback HTTP callback fixture 验证请求/响应边界,但不代表真实 Mind PostgreSQL 归属核验或企业纪要恢复。
|
- public HTTP/WS/CORS 回归证明 Cookie 授权保持,旧 summary headers 不改变路由选择。
|
||||||
- Bad:仅比较 Token 后把 workspace header 拼成已授权 scope,或给后台伪造用户/binding 来复用 WS reader。
|
- release 静态检查证明 token、callback 和 secret 注入已删除;部署时用 `docker network inspect`、`docker port` 与 Mind 容器真实请求验证网络。
|
||||||
- Bad:缺配置返回空成功页,鉴权失败改走 Cookie,或者更改 Center 分页来掩盖 Mind 跨页排序错误。
|
- 真实 Docker 网络不可用时单列 external-unverified,不能以 HTTP injection 宣称已经完成双容器验收。
|
||||||
|
|
||||||
## 6. Tests Required
|
|
||||||
|
|
||||||
- 严格 contract decoder:未知字段、缺字段、空标识、purpose/permissions、无 user/binding 及稳定拒绝 code。
|
|
||||||
- HTTP:正确/错误/缺失 token,混合 Cookie 凭据,错 workspace/account/conversation、错误 purpose、回调错误/超时/重定向均不能越权;Cookie/CORS/WS 回归。
|
|
||||||
- 100+ 消息验证多页向更旧读取、固定范围、同毫秒消息、跨范围 cursor 和 history_incomplete;每页都回调授权。
|
|
||||||
- 日志和发布:真正运行入口有可观察输出,Token/Cookie/正文不出现在输出;release workflow 从 secret 到 server.env 链完整。
|
|
||||||
- 源码/构建单测、typecheck、build、format、真实 loopback HTTP;后端测试硬超时 60 秒。PostgreSQL/真实 Mind/页面恢复未运行时必须单列,不能用 mock 替代验收结论。
|
|
||||||
|
|
||||||
## 7. Wrong vs Correct
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Wrong: caller 头信息并不是已授权事实。
|
|
||||||
const scope = { workspaceId: request.headers["x-mind-workspace-id"], channelAccountId };
|
|
||||||
return readHistory(scope);
|
|
||||||
|
|
||||||
// Correct: 只有独立回调授权的精确 scope 才能进入历史读取。
|
|
||||||
const decision = await summaryAuthorization.authorize(summaryRequest, authorizationHeader);
|
|
||||||
if (!decision.allowed) return sendAuthorizationFailure(decision.code);
|
|
||||||
return readHistory(decision.authorization.scope);
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/project/architecture.md","reason":"Review application/runtime ownership after introducing the internal listener."}
|
||||||
|
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Review required focused tests, static checks, and production-like verification boundary."}
|
||||||
|
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Review public/private error isolation and absence of auth fallbacks."}
|
||||||
|
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/prd.md","reason":"Check the implementation against accepted scope, exposure, and documentation criteria."}
|
||||||
|
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/design.md","reason":"Check listener, Docker-network, rollback, and ownership invariants."}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Center 摘要内部网络入口设计
|
||||||
|
|
||||||
|
## 设计结论
|
||||||
|
|
||||||
|
摘要历史读取不再是 public history route 的认证分支。一个 Node 进程内运行两个独立 Fastify listener:public app 继续在 `7878` 服务全部既有能力;internal summary app 固定在 `0.0.0.0:7777`,只注册 history GET。Docker 的 `trade-message-center-summary` internal network 及成员资格成为 summary 的唯一权限边界。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet / extension / Mind page
|
||||||
|
|
|
||||||
|
host publish :7878
|
||||||
|
|
|
||||||
|
Center public Fastify app
|
||||||
|
Cookie + Mind page authorization, WebSocket, normal HTTP
|
||||||
|
|
||||||
|
Mind container -- trade-message-center-summary (internal Docker network)
|
||||||
|
|
|
||||||
|
http://trade-message-center:7777
|
||||||
|
|
|
||||||
|
Center internal summary Fastify app
|
||||||
|
history GET only; no Cookie/Bearer/header authorization or Mind callback
|
||||||
|
|
|
||||||
|
shared OneTalk read service -> Bright PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
The two containers must run on the same Docker daemon. The network prevents Docker port publication for `7777`; it is not encryption, mTLS, or a defence against another member container, a Docker-socket holder, or host root.
|
||||||
|
|
||||||
|
## Listener and runtime ownership
|
||||||
|
|
||||||
|
- Keep the existing `createApp` as the public application factory. Its read routes retain the complete Cookie/Mind reader and must no longer branch to summary authorization when `Authorization`, `X-Mind-Purpose`, or `X-Mind-Workspace-Id` appears.
|
||||||
|
- Add a dedicated internal app factory that registers only the existing history route constant and a handler which invokes the canonical `OneTalkReadService.readHistory`. It registers neither WebSocket, CORS/origin guards, extension-download routes, list/detail routes, nor generic Mind authorization.
|
||||||
|
- Add a runtime composition boundary that creates one `DatabaseConnection`, one read repository/service, and one cutover policy, injects the shared read service into both apps, starts public `config.host:config.port` and internal `0.0.0.0:7777`, and owns ordered shutdown. Close the internal app before the public app so the public app's existing database-close hook remains the one close owner.
|
||||||
|
- Failure to bind either listener closes both apps and fails process startup. No port fallback, disabled internal route, or default-to-public behavior is allowed.
|
||||||
|
- Preserve the current read-service facts: only `channelAccountId` is needed for a history query, opaque cursors remain bound to account/conversation/window/asOf, summary mode still requires both time boundaries and keeps `history_incomplete` plus `Retry-After: 30`.
|
||||||
|
|
||||||
|
## Internal HTTP contract
|
||||||
|
|
||||||
|
Mind calls the same history path on the private port to minimize path migration:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=:inclusiveEpochMs&toSentAtMs=:exclusiveEpochMs&cursor=:opaqueCursor&limit=:1to100
|
||||||
|
```
|
||||||
|
|
||||||
|
- The internal listener itself selects summary mode. It ignores no authorization claim because it reads none: `Authorization`, Cookie, `X-Mind-Purpose`, and `X-Mind-Workspace-Id` are not request-contract fields and the Mind document tells callers to omit them.
|
||||||
|
- A successful internal response contains `conversationId`, semantic `messages`, and `page`; it has no `scope`, `workspaceId`, `mindUserId`, binding, authorization version, or authorization diagnostics. `channelAccountId` and `conversationId` are the request path identity, not a returned authorization claim.
|
||||||
|
- Input/database/history errors keep the existing stable response shape. `auth_required`, `scope_mismatch`, `authorization_rejected`, and `authorization_unavailable` are not internal-summary outcomes.
|
||||||
|
- The public route ignores the former summary headers and only executes the existing Cookie/Mind page authorization path. Therefore an unauthenticated public request cannot reach a summary read through `7878`.
|
||||||
|
|
||||||
|
`ONETALK_SUMMARY_READ_PURPOSE` remains only as a server-domain signal between the internal HTTP handler and `OneTalkReadService`; move it out of the shared summary-authorization contract so it cannot be interpreted as an external header contract.
|
||||||
|
|
||||||
|
## Removed and preserved boundaries
|
||||||
|
|
||||||
|
Remove the summary-specific shared contract and export, credential predicate, summary reader/callback client, `TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` config/env/release secret flow, workspace/purpose headers, callback diagnostics, response-scope union, and their tests. Update the prior summary-authorization server spec instead of leaving it as active guidance.
|
||||||
|
|
||||||
|
Do not remove `MIND_AUTH_BASE_URL`, its generic reader, Cookie authorization, WebSocket authorization, CORS for the public app, public `7878`, or any history/repository/cursor/projection behavior. No migration or Mind database access is introduced.
|
||||||
|
|
||||||
|
## Docker release contract
|
||||||
|
|
||||||
|
- The deploy workflow uses the fixed named network `trade-message-center-summary`. Before each start or restore, it verifies that an existing network is `Internal=true`, or creates it with `docker network create --internal`.
|
||||||
|
- Center starts on the named non-internal `trade-message-center-public` bridge network for its published `7878` endpoint and outbound generic Mind authorization calls. It must not remain on Docker's default bridge, where other default-bridge members could reach every listening port by container IP. After `docker run --network trade-message-center-public --publish 7878:7878`, the workflow attaches it to `trade-message-center-summary`; it must not publish `7777`.
|
||||||
|
- Docker's automatic alias for the existing container name `trade-message-center` is the internal DNS target. The Mind owner attaches its container to the same named network and calls the URL above.
|
||||||
|
- The Dockerfile documents both container ports with `EXPOSE 7878 7777`, but `EXPOSE` is not an exposure control. The existing health check stays on `127.0.0.1:$PORT/health` and covers the public app; internal request smoke verification is a deployment check.
|
||||||
|
- Network creation/attachment failure fails release and follows the existing image/env rollback path. The network is not removed on rollback because it is shared with Mind. Rolling back Center after Mind changes back to `7777` requires coordinated Mind rollback or its traffic will fail; document this explicitly.
|
||||||
|
|
||||||
|
## Documentation and external handoff
|
||||||
|
|
||||||
|
Create `docs/onetalk-summary-internal-api.md` as the handoff document for Mind. It will contain the exact URL/path/query, request/response/error rules, network name, `docker network connect` example, no-port-publication rule, verification commands, shared-daemon precondition, and the deliberate loss of workspace/conversation authorization. Update `docs/bright-conversation-list-api.md` so its public summary-header section no longer contradicts the internal contract and links to the new document.
|
||||||
|
|
||||||
|
Mind code/deployment changes are not part of this task. A live Mind-to-Center container request is external validation, not evidence obtainable from Center unit tests.
|
||||||
|
|
||||||
|
## Test and rollback boundaries
|
||||||
|
|
||||||
|
- Unit and HTTP-injection tests prove the private handler is summary mode without credentials, validates windows, returns no scope, and preserves cursor/history gates. Public regression tests prove Bearer/workspace/purpose inputs do not select an unauthenticated route and that Cookie/WS behavior is unchanged.
|
||||||
|
- Runtime tests use ephemeral ports through the new runtime composition seam to prove both listener lifecycles and bind failure cleanup without depending on host port `7777`.
|
||||||
|
- Static checks prove summary credentials/callback modules and active config/release references are gone; archived task evidence is excluded from this deletion check.
|
||||||
|
- Docker daemon access is currently unavailable locally, so `docker network inspect`, `docker port`, and container-to-container HTTP are explicitly external deployment verification.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/project/architecture.md","reason":"Defines shared app/module boundaries for adding a second listener without creating a second business owner."}
|
||||||
|
{"file":".trellis/spec/project/missing-values.md","reason":"Required for the fixed internal port and removal of the summary credential configuration."}
|
||||||
|
{"file":".trellis/spec/server/backend/service-foundation.md","reason":"Defines Fastify lifecycle, application composition, and server runtime conventions."}
|
||||||
|
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Defines stable HTTP error and security-boundary behavior for the internal handler."}
|
||||||
|
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/design.md","reason":"Approved task architecture, rollout boundary, and exact private listener contract."}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Center 摘要内部网络入口实施计划
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Preserve the current dirty working tree. The existing summary-authorization diff is task-relevant input; make targeted replacements only and do not reset, checkout, or discard unrelated user work.
|
||||||
|
- Before changing any symbol, run GitNexus upstream impact for `loadConfig`, `createApp`, `startServer`, and `installOneTalkReadRoutes`. Report any HIGH/CRITICAL result before editing.
|
||||||
|
- Before code edits, load `trellis-before-dev` and the project/server specs it routes to; it must cover config/default handling, HTTP errors, module ownership, service foundation, and quality checks.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Replace the shared summary-authorization contract with a server-local summary-read purpose owned by the read model/service boundary. Remove its package export and all token/callback/credential modules and dependencies.
|
||||||
|
2. Simplify `loadConfig` and environment examples: remove the summary token configuration and validation, retain generic Mind authorization, and define the fixed internal listener contract without creating an environment-configurable fallback port.
|
||||||
|
3. Extract a runtime composition seam that owns one database/read-service/cutover-policy instance and can start/close both Fastify apps in deterministic order. Keep `createApp` compatible with existing direct tests and its existing database close behavior.
|
||||||
|
4. Make the public read-route installer use only the Cookie/Mind page authorization path; remove summary header/Bearer selection, summary callback diagnostics, and summary scope response handling.
|
||||||
|
5. Add the internal summary-only app and history route. It calls the same read service with server-selected summary purpose, accepts only the documented path/query contract, returns no authorization scope, and never installs public-only routes or CORS/origin authorization.
|
||||||
|
6. Change the entrypoint to bind public `HOST:PORT` and private `0.0.0.0:7777`; make startup and SIGINT/SIGTERM close both safely, failing loudly if either bind fails.
|
||||||
|
7. Update `Dockerfile.server` and release deployment: document both container ports, remove token secret transfer and required checks, create/validate `trade-message-center-summary` as an internal network, attach Center after every launch/restore, and leave only `7878` published. Preserve atomic env/image rollback behavior.
|
||||||
|
8. Update public API documentation and create the Mind handoff document. State that Mind deployment must join the network, use `trade-message-center:7777`, omit the old headers/credentials, and coordinate rollback.
|
||||||
|
9. Remove or rewrite summary-specific tests and add focused public/private/runtime/release-contract tests. Do not change repository, cursor, projection, migration, WebSocket, or generic Mind authorization tests except for imports/expectations invalidated by the removed summary feature.
|
||||||
|
10. Run `detect_changes()` before any commit, inspect the diff for unintentional public exposure, duplicated history logic, ownership regressions, silent fallbacks, and secrets/log leaks.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
1. Focused Node tests for config/app/entry/public HTTP/internal summary HTTP and summary-removal assertions, each with a 60-second timeout.
|
||||||
|
2. `pnpm typecheck`, `pnpm build`, `pnpm test`, `pnpm format:check`, and `git diff --check`.
|
||||||
|
3. Verify the release script's network creation/attachment branches and both rollback paths by static/unit coverage where possible; run workflow syntax validation if the available tooling supports it.
|
||||||
|
4. External production-like validation by the deployment owner: `docker network inspect trade-message-center-summary`, `docker port trade-message-center`, and an actual Mind-container request to `http://trade-message-center:7777/...`. Record Docker access as unverified if unavailable.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Revert Center image and its release workflow/env together. The old image has no internal `7777` handler, so the Mind owner must revert its base URL/headers in the same change window.
|
||||||
|
- Keep the named Docker network intact; it is shared infrastructure and deleting it is outside this task.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Center 摘要内部网络入口
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
用同一 Docker daemon 中受限成员的私有网络,替代 Center 摘要历史读取的 Bearer、共享 token 与 Mind `authorize-summary` 回调。Mind 只经 Center 的内部 `7777` 监听器读取摘要;既有面向扩展和页面的 `7878` 服务保持不变。
|
||||||
|
|
||||||
|
## 已确认事实
|
||||||
|
|
||||||
|
- 已归档任务 `09-07-onetalk-summary-read-authorization` 为摘要 history GET 增加了 Bearer、`X-Mind-Purpose`、`X-Mind-Workspace-Id`、`TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` 与 Center→Mind `POST /internal/bright/onetalk/authorize-summary`;这些仅是摘要专用机制。
|
||||||
|
- 当前 `startServer` 只调用一次 `app.listen({ host: config.host, port: config.port })`;`createApp` 将 public HTTP、WebSocket、页面/插件授权和 summary route 装配为同一 Fastify app。
|
||||||
|
- 当前 release workflow 只以 `docker run --publish 7878:7878` 运行 Center,未创建或加入供 Mind 共用的命名网络。
|
||||||
|
- 用户已确认:Mind 与 Center 将运行在同一个 Docker daemon;摘要读取的权限边界改为该私有网络的成员资格。该边界不提供 workspace/conversation 级授权,具备该网络或 Docker daemon 管理权限的主体均可访问摘要接口。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- R1:保留 public `7878` listener、Cookie 页面授权、扩展 WebSocket、普通读取、发送、health 和现有发布端口;不得以改写唯一 `PORT` 的方式把整个 Bright 服务迁至 7777。
|
||||||
|
- R2:新增独立 `7777` internal listener,只暴露摘要 history GET;public listener 的既有 health check 保持不变。internal handler 直接选择 summary read,继续使用既有固定 `[fromSentAtMs,toSentAtMs)`、opaque cursor、`history_incomplete` 和只读 service 语义。
|
||||||
|
- R3:internal listener 不读取或校验 summary Bearer、`X-Mind-Purpose`、`X-Mind-Workspace-Id`,也不回调 Mind 归属服务。摘要响应只返回会话、消息与分页数据,不得伪造或回显 workspace/mind user/binding;Mind 自己持有调用的 workspace 上下文。
|
||||||
|
- R4:删除仅由摘要互信引入的 Center contract、credential/config、callback client、diagnostic、测试、示例环境变量和 release secret 注入。保留供 public 页面与插件使用的通用 Mind authorization reader、其 `MIND_AUTH_BASE_URL` 配置与 WebSocket 授权。
|
||||||
|
- R5:发布在一个仅含 Mind 与 Center 的命名 Docker `internal` 网络上提供 `7777`;Center 先加入仅供其自身 egress/public port 的命名 bridge network,再挂载该 internal network,不能留在 Docker 默认 bridge。Center 仍发布 `7878`,绝不 `--publish`/Compose `ports` 映射 `7777`。内部监听必须绑定 `0.0.0.0:7777`,以供对等容器访问;镜像 `EXPOSE` 不得被误当作访问控制。
|
||||||
|
- R6:网络成员资格是唯一摘要访问控制。不得添加 token、Cookie fallback、IP allowlist、workspace header 或从 Center 数据行反推 workspace 的替代机制;也不得将无鉴权摘要路由注册到 public listener。
|
||||||
|
- R7:交付给 Mind 的对接文档,固定内部 URL、无认证请求形状、网络名称/加入方式、不可用边界、部署验收命令和明确的 Docker-daemon 前提;不在本仓库修改 Mind 代码或其部署。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Mind 容器在 `trade-message-center-summary` 网络中,能不带 Bearer 和 Mind workspace/purpose header 访问 Center `:7777` 的摘要 history;固定时间窗、分页、`history_incomplete` 和数据库只读行为保持既有契约。
|
||||||
|
- [ ] 同一无鉴权请求通过 public `:7878` 不会获得摘要消息;public listener 的 Cookie 授权、CORS、列表/详情/消息、WebSocket、发送和 health 回归通过。
|
||||||
|
- [ ] Center 运行时源码、配置、示例 env、CI/release 和活跃测试不再引用 summary token、`authorize-summary` 或摘要 callback;通用 Mind authorization 和页面/插件授权仍可用。
|
||||||
|
- [ ] 发布脚本创建/复用受限命名网络、将 Center 接入它,并且只发布 `7878`。部署验收用 `docker network inspect`、`docker port` 和真实 Mind→Center internal HTTP 请求证明网络可达与 `7777` 未对宿主机发布。
|
||||||
|
- [ ] Mind 对接文档可让其所有者在同一 Docker daemon 将 Mind 容器加入私有网络、改用内部 `7777` URL,且明确此网络成员可读取任意摘要 account/conversation,不应保留 Bearer/workspace/purpose header。
|
||||||
|
- [ ] 目标单测、typecheck、build、格式检查和 diff review 通过;真实双容器部署验证若当前环境不可得,必须以外部未验证项报告,不能用单机 mock 代替。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 在本仓库修改 Mind 的业务读取逻辑、数据库 schema 或其部署仓库;Mind 将请求内部 URL 并加入命名网络的改动由其所有者交付。
|
||||||
|
- workspace、账号或会话级授权;它们与“网络成员即授权”的已确认边界冲突。
|
||||||
|
- 将 `7777` 暴露给宿主机、互联网、跨 Docker daemon、Kubernetes 或 Docker Swarm 网络;若 Mind 与 Center 不在同一 Docker daemon,本设计不适用。
|
||||||
|
- 数据库迁移、消息投影、历史 cursor/排序语义、通用 Mind authorization 的重构。
|
||||||
|
|
||||||
|
## Delivery Boundary
|
||||||
|
|
||||||
|
Center 的 release workflow 负责创建/复用私有网络并把 Center 接入其中。Mind 所有者依据本任务交付的文档,将其容器接入同一网络并改用 internal URL;该外部操作是端到端部署验收的前置条件,不是本仓库的代码变更。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "onetalk-summary-internal-network",
|
||||||
|
"name": "onetalk-summary-internal-network",
|
||||||
|
"title": "Center 摘要内部网络入口",
|
||||||
|
"description": "以同 Docker 内部网络的 7777 摘要入口替代摘要专用 Bearer 与 Mind 回调授权。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "ybf",
|
||||||
|
"assignee": "ybf",
|
||||||
|
"createdAt": "2026-09-07",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": "codex/onetalk-summary-read-authorization",
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
+1
-1
@@ -30,7 +30,7 @@ ENV NODE_ENV=production
|
|||||||
|
|
||||||
COPY --from=build /opt/server ./
|
COPY --from=build /opt/server ./
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT} 7777
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
||||||
CMD node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"
|
CMD node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export const createOneTalkHarnessHtml = (
|
|||||||
<label>OneTalk 账号 ID<input id="account-id" autocomplete="off" placeholder="onetalk-account-1" required></label>
|
<label>OneTalk 账号 ID<input id="account-id" autocomplete="off" placeholder="onetalk-account-1" required></label>
|
||||||
<label>会话查询<input id="conversation-query" autocomplete="off" placeholder="名称或会话 ID"></label>
|
<label>会话查询<input id="conversation-query" autocomplete="off" placeholder="名称或会话 ID"></label>
|
||||||
<label>会话<select id="conversation-id" disabled><option value="">先加载会话</option></select></label>
|
<label>会话<select id="conversation-id" disabled><option value="">先加载会话</option></select></label>
|
||||||
<label>读取目的<select id="read-purpose"><option value="normal">普通读取</option><option value="communication_summary_read">纪要读取</option></select></label>
|
|
||||||
<label>起始时间(含)<input id="from-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
|
<label>起始时间(含)<input id="from-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
|
||||||
<label>结束时间(不含)<input id="to-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
|
<label>结束时间(不含)<input id="to-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
|
||||||
<label>开发 Cookie<input id="mind-cookie" autocomplete="off" placeholder="mind_session=opaque"></label>
|
<label>开发 Cookie<input id="mind-cookie" autocomplete="off" placeholder="mind_session=opaque"></label>
|
||||||
@@ -71,7 +70,7 @@ export const createOneTalkHarnessHtml = (
|
|||||||
<label>发送内容<input id="send-content" autocomplete="off" placeholder="输入要发送的文本"></label>
|
<label>发送内容<input id="send-content" autocomplete="off" placeholder="输入要发送的文本"></label>
|
||||||
<button id="send" class="secondary" type="button" disabled>发送</button>
|
<button id="send" class="secondary" type="button" disabled>发送</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor;纪要读取会发送 X-Mind-Purpose 并要求完整时间窗。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
|
<p class="hint">HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" aria-labelledby="status-heading">
|
<section class="panel" aria-labelledby="status-heading">
|
||||||
@@ -118,7 +117,6 @@ export const createOneTalkHarnessHtml = (
|
|||||||
account: element('account-id'),
|
account: element('account-id'),
|
||||||
conversationQuery: element('conversation-query'),
|
conversationQuery: element('conversation-query'),
|
||||||
conversation: element('conversation-id'),
|
conversation: element('conversation-id'),
|
||||||
readPurpose: element('read-purpose'),
|
|
||||||
fromSentAtMs: element('from-sent-at-ms'),
|
fromSentAtMs: element('from-sent-at-ms'),
|
||||||
toSentAtMs: element('to-sent-at-ms'),
|
toSentAtMs: element('to-sent-at-ms'),
|
||||||
cookie: element('mind-cookie'),
|
cookie: element('mind-cookie'),
|
||||||
@@ -230,8 +228,6 @@ export const createOneTalkHarnessHtml = (
|
|||||||
channelAccountId: scope.channelAccountId
|
channelAccountId: scope.channelAccountId
|
||||||
} : null;
|
} : null;
|
||||||
};
|
};
|
||||||
const readPurpose = () => fields.readPurpose.value === 'communication_summary_read' ? 'communication_summary_read' : 'normal';
|
|
||||||
|
|
||||||
const apiUrlFor = (channelAccountId, conversationId) => {
|
const apiUrlFor = (channelAccountId, conversationId) => {
|
||||||
const base = conversationRoute.replace(':channelAccountId', encodeURIComponent(channelAccountId));
|
const base = conversationRoute.replace(':channelAccountId', encodeURIComponent(channelAccountId));
|
||||||
const path = base + (conversationId ? '/' + encodeURIComponent(conversationId) : '');
|
const path = base + (conversationId ? '/' + encodeURIComponent(conversationId) : '');
|
||||||
@@ -260,10 +256,6 @@ export const createOneTalkHarnessHtml = (
|
|||||||
return url + '?' + query.toString();
|
return url + '?' + query.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestHeadersForHistory = () => readPurpose() === 'communication_summary_read'
|
|
||||||
? { 'X-Mind-Purpose': 'communication_summary_read' }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const readResponse = async (url, requestHeaders = {}) => {
|
const readResponse = async (url, requestHeaders = {}) => {
|
||||||
const response = await fetch(url, { headers: requestHeaders, credentials: 'include' });
|
const response = await fetch(url, { headers: requestHeaders, credentials: 'include' });
|
||||||
let body = null;
|
let body = null;
|
||||||
@@ -585,7 +577,7 @@ export const createOneTalkHarnessHtml = (
|
|||||||
if (state.historyRequestGeneration !== requestGeneration || state.conversationId !== conversationId) return;
|
if (state.historyRequestGeneration !== requestGeneration || state.conversationId !== conversationId) return;
|
||||||
const url = historyUrlFor();
|
const url = historyUrlFor();
|
||||||
if (!url) throw new Error("scope_mismatch");
|
if (!url) throw new Error("scope_mismatch");
|
||||||
const body = await readResponse(url, requestHeadersForHistory());
|
const body = await readResponse(url);
|
||||||
if (state.historyRequestGeneration !== requestGeneration || state.conversationId !== conversationId) return;
|
if (state.historyRequestGeneration !== requestGeneration || state.conversationId !== conversationId) return;
|
||||||
if (!isHistoryResponse(body)) throw new Error('invalid_response');
|
if (!isHistoryResponse(body)) throw new Error('invalid_response');
|
||||||
if (!currentScope() || body.conversationId !== conversationId || body.scope.mindUserId !== scope.mindUserId || body.scope.workspaceId !== scope.workspaceId || body.scope.channelAccountId !== scope.channelAccountId) throw new Error('scope_mismatch');
|
if (!currentScope() || body.conversationId !== conversationId || body.scope.mindUserId !== scope.mindUserId || body.scope.workspaceId !== scope.workspaceId || body.scope.channelAccountId !== scope.channelAccountId) throw new Error('scope_mismatch');
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export * from "./authorization.ts";
|
|||||||
export * from "./content.ts";
|
export * from "./content.ts";
|
||||||
export * from "./decoder.ts";
|
export * from "./decoder.ts";
|
||||||
export * from "./model.ts";
|
export * from "./model.ts";
|
||||||
export * from "./summary-authorization.ts";
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// 定义纪要历史读取的专用授权契约
|
|
||||||
|
|
||||||
export const ONETALK_SUMMARY_READ_PURPOSE = "communication_summary_read" as const;
|
|
||||||
|
|
||||||
export type OneTalkSummaryReadScope = {
|
|
||||||
workspaceId: string;
|
|
||||||
channelAccountId: string;
|
|
||||||
conversationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OneTalkSummaryReadAuthorizationRequest = OneTalkSummaryReadScope & {
|
|
||||||
purpose: typeof ONETALK_SUMMARY_READ_PURPOSE;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OneTalkSummaryReadAuthorization = {
|
|
||||||
purpose: typeof ONETALK_SUMMARY_READ_PURPOSE;
|
|
||||||
scope: OneTalkSummaryReadScope;
|
|
||||||
permissions: ["read"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OneTalkSummaryReadAuthorizationRejection = {
|
|
||||||
code:
|
|
||||||
| "auth_required"
|
|
||||||
| "invalid_request"
|
|
||||||
| "scope_forbidden"
|
|
||||||
| "summary_workspace_disabled"
|
|
||||||
| "authorization_unavailable";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OneTalkSummaryReadAuthorizationDecodeResult =
|
|
||||||
| { ok: true; authorization: OneTalkSummaryReadAuthorization }
|
|
||||||
| { ok: false; rejection?: OneTalkSummaryReadAuthorizationRejection };
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
|
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean =>
|
|
||||||
Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
|
||||||
|
|
||||||
const isIdentifier = (value: unknown): value is string =>
|
|
||||||
typeof value === "string" &&
|
|
||||||
value.length > 0 &&
|
|
||||||
value === value.trim() &&
|
|
||||||
!/[\u0000-\u001f\u007f]/u.test(value);
|
|
||||||
|
|
||||||
/** 解码 Mind 的精确纪要授权响应,未知形状一律拒绝。 */
|
|
||||||
export const decodeOneTalkSummaryReadAuthorization = (
|
|
||||||
value: unknown,
|
|
||||||
): OneTalkSummaryReadAuthorizationDecodeResult => {
|
|
||||||
if (!isRecord(value)) return { ok: false };
|
|
||||||
if (hasExactKeys(value, ["code"]) && typeof value.code === "string") {
|
|
||||||
if (
|
|
||||||
value.code === "auth_required" ||
|
|
||||||
value.code === "invalid_request" ||
|
|
||||||
value.code === "scope_forbidden" ||
|
|
||||||
value.code === "summary_workspace_disabled" ||
|
|
||||||
value.code === "authorization_unavailable"
|
|
||||||
) {
|
|
||||||
return { ok: false, rejection: { code: value.code } };
|
|
||||||
}
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
if (!hasExactKeys(value, ["purpose", "scope", "permissions"]) || !isRecord(value.scope)) {
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
value.purpose !== ONETALK_SUMMARY_READ_PURPOSE ||
|
|
||||||
!hasExactKeys(value.scope, ["workspaceId", "channelAccountId", "conversationId"]) ||
|
|
||||||
!isIdentifier(value.scope.workspaceId) ||
|
|
||||||
!isIdentifier(value.scope.channelAccountId) ||
|
|
||||||
!isIdentifier(value.scope.conversationId) ||
|
|
||||||
!Array.isArray(value.permissions) ||
|
|
||||||
value.permissions.length !== 1 ||
|
|
||||||
value.permissions[0] !== "read"
|
|
||||||
) {
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
authorization: {
|
|
||||||
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
scope: {
|
|
||||||
workspaceId: value.scope.workspaceId,
|
|
||||||
channelAccountId: value.scope.channelAccountId,
|
|
||||||
conversationId: value.scope.conversationId,
|
|
||||||
},
|
|
||||||
permissions: ["read"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// 验证纪要读取授权协议的严格解码边界
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
decodeOneTalkSummaryReadAuthorization,
|
|
||||||
ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
} from "../src/index.ts";
|
|
||||||
|
|
||||||
const authorization = {
|
|
||||||
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
scope: {
|
|
||||||
workspaceId: "803937a7-f7d3-497d-a5ec-b99d0314669e",
|
|
||||||
channelAccountId: "account-1",
|
|
||||||
conversationId: "conversation-1",
|
|
||||||
},
|
|
||||||
permissions: ["read"],
|
|
||||||
};
|
|
||||||
|
|
||||||
test("decodes only the exact summary read grant shape", () => {
|
|
||||||
assert.deepEqual(decodeOneTalkSummaryReadAuthorization(authorization), {
|
|
||||||
ok: true,
|
|
||||||
authorization,
|
|
||||||
});
|
|
||||||
for (const value of [
|
|
||||||
{ ...authorization, permissions: ["read", "send"] },
|
|
||||||
{ ...authorization, scope: { ...authorization.scope, mindUserId: "invented" } },
|
|
||||||
{ ...authorization, purpose: "other" },
|
|
||||||
{ ...authorization, unexpected: true },
|
|
||||||
]) {
|
|
||||||
assert.deepEqual(decodeOneTalkSummaryReadAuthorization(value), { ok: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decodes only registered summary authorization rejections", () => {
|
|
||||||
assert.deepEqual(decodeOneTalkSummaryReadAuthorization({ code: "scope_forbidden" }), {
|
|
||||||
ok: false,
|
|
||||||
rejection: { code: "scope_forbidden" },
|
|
||||||
});
|
|
||||||
assert.deepEqual(decodeOneTalkSummaryReadAuthorization({ code: "unknown" }), { ok: false });
|
|
||||||
});
|
|
||||||
@@ -28,12 +28,6 @@ import type { OneTalkConnectionRegistry, OneTalkPublishFailureSink } from "./web
|
|||||||
import type { OneTalkProfileService, OneTalkReadService, OneTalkService } from "./onetalk/index.ts";
|
import type { OneTalkProfileService, OneTalkReadService, OneTalkService } from "./onetalk/index.ts";
|
||||||
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
|
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
|
||||||
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
|
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
|
||||||
import {
|
|
||||||
createSummaryAuthorizationReader,
|
|
||||||
createUnavailableSummaryAuthorizationReader,
|
|
||||||
type SummaryAuthorizationDiagnosticsSink,
|
|
||||||
type SummaryAuthorizationReader,
|
|
||||||
} from "./summary-authorization.ts";
|
|
||||||
|
|
||||||
export type AppDependencies = {
|
export type AppDependencies = {
|
||||||
database?: DatabaseConnection;
|
database?: DatabaseConnection;
|
||||||
@@ -45,26 +39,9 @@ export type AppDependencies = {
|
|||||||
onOneTalkPublishFailure?: OneTalkPublishFailureSink;
|
onOneTalkPublishFailure?: OneTalkPublishFailureSink;
|
||||||
onOneTalkDiagnostic?: OneTalkDiagnosticsSink;
|
onOneTalkDiagnostic?: OneTalkDiagnosticsSink;
|
||||||
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink;
|
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink;
|
||||||
onSummaryAuthorizationDiagnostic?: SummaryAuthorizationDiagnosticsSink;
|
|
||||||
summaryAuthorization?: SummaryAuthorizationReader;
|
|
||||||
cutoverPolicy?: OneTalkCutoverPolicy;
|
cutoverPolicy?: OneTalkCutoverPolicy;
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryAuthorizationFor = (
|
|
||||||
config: ServerConfig,
|
|
||||||
explicitAuthorization: SummaryAuthorizationReader | undefined,
|
|
||||||
onDiagnostic: SummaryAuthorizationDiagnosticsSink | undefined,
|
|
||||||
): SummaryAuthorizationReader =>
|
|
||||||
explicitAuthorization ??
|
|
||||||
(config.mindAuthorization && config.summaryReadAuthorization
|
|
||||||
? createSummaryAuthorizationReader({
|
|
||||||
baseUrl: config.mindAuthorization.baseUrl,
|
|
||||||
timeoutMs: config.mindAuthorization.timeoutMs,
|
|
||||||
token: config.summaryReadAuthorization.token,
|
|
||||||
onDiagnostic,
|
|
||||||
})
|
|
||||||
: createUnavailableSummaryAuthorizationReader(onDiagnostic));
|
|
||||||
|
|
||||||
const authorizationFor = (
|
const authorizationFor = (
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
explicitAuthorization: OneTalkAuthorizationReader | undefined,
|
explicitAuthorization: OneTalkAuthorizationReader | undefined,
|
||||||
@@ -89,11 +66,6 @@ export const createApp = (
|
|||||||
dependencies.authorization,
|
dependencies.authorization,
|
||||||
dependencies.onMindAuthorizationDiagnostic,
|
dependencies.onMindAuthorizationDiagnostic,
|
||||||
);
|
);
|
||||||
const summaryAuthorization = summaryAuthorizationFor(
|
|
||||||
config,
|
|
||||||
dependencies.summaryAuthorization,
|
|
||||||
dependencies.onSummaryAuthorizationDiagnostic,
|
|
||||||
);
|
|
||||||
const cutoverPolicy = dependencies.cutoverPolicy ?? createOneTalkCutoverPolicy();
|
const cutoverPolicy = dependencies.cutoverPolicy ?? createOneTalkCutoverPolicy();
|
||||||
const mindPageOrigin =
|
const mindPageOrigin =
|
||||||
config.mindAuthorization?.mindPageOrigin ??
|
config.mindAuthorization?.mindPageOrigin ??
|
||||||
@@ -130,8 +102,6 @@ export const createApp = (
|
|||||||
authorization,
|
authorization,
|
||||||
readService,
|
readService,
|
||||||
registry: oneTalkRegistry,
|
registry: oneTalkRegistry,
|
||||||
summaryAuthorization,
|
|
||||||
onSummaryAuthorizationDiagnostic: dependencies.onSummaryAuthorizationDiagnostic,
|
|
||||||
mindPageOrigin,
|
mindPageOrigin,
|
||||||
cutoverPolicy,
|
cutoverPolicy,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
validateChromeExtensionDownloadConfig,
|
validateChromeExtensionDownloadConfig,
|
||||||
type ChromeExtensionDownloadConfig,
|
type ChromeExtensionDownloadConfig,
|
||||||
} from "./oss/chrome-extension-download.ts";
|
} from "./oss/chrome-extension-download.ts";
|
||||||
import { isSummaryReadToken } from "./summary-credential.ts";
|
|
||||||
|
|
||||||
export type ServerConfig = {
|
export type ServerConfig = {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -13,7 +12,6 @@ export type ServerConfig = {
|
|||||||
environment: ServerEnvironment;
|
environment: ServerEnvironment;
|
||||||
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
|
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
|
||||||
mindAuthorization?: MindAuthorizationConfig;
|
mindAuthorization?: MindAuthorizationConfig;
|
||||||
summaryReadAuthorization?: SummaryReadAuthorizationConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MindAuthorizationConfig = {
|
export type MindAuthorizationConfig = {
|
||||||
@@ -23,10 +21,6 @@ export type MindAuthorizationConfig = {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SummaryReadAuthorizationConfig = {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readServerEnvironment = (): NodeJS.ProcessEnv => process.env;
|
export const readServerEnvironment = (): NodeJS.ProcessEnv => process.env;
|
||||||
|
|
||||||
export type ServerEnvironment = "development" | "non_development";
|
export type ServerEnvironment = "development" | "non_development";
|
||||||
@@ -119,17 +113,6 @@ const parseMindAuthorization = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseSummaryReadAuthorization = (
|
|
||||||
environment: Record<string, string | undefined>,
|
|
||||||
): SummaryReadAuthorizationConfig | undefined => {
|
|
||||||
const value = environment.TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN;
|
|
||||||
if (value === undefined || value === "") return undefined;
|
|
||||||
if (!isSummaryReadToken(value)) {
|
|
||||||
throw new Error("Invalid TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN");
|
|
||||||
}
|
|
||||||
return { token: value };
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseChromeExtensionDownload = (
|
const parseChromeExtensionDownload = (
|
||||||
environment: Record<string, string | undefined>,
|
environment: Record<string, string | undefined>,
|
||||||
): ChromeExtensionDownloadConfig | undefined => {
|
): ChromeExtensionDownloadConfig | undefined => {
|
||||||
@@ -162,6 +145,5 @@ export const loadConfig = (environment: Record<string, string | undefined>): Ser
|
|||||||
environment,
|
environment,
|
||||||
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
|
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
|
||||||
),
|
),
|
||||||
summaryReadAuthorization: parseSummaryReadAuthorization(environment),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import { createApp } from "./app.ts";
|
|
||||||
import { loadConfig, readServerEnvironment } from "./config.ts";
|
import { loadConfig, readServerEnvironment } from "./config.ts";
|
||||||
import type { MindAuthorizationDiagnosticsSink } from "./mind-authorization.ts";
|
import type { MindAuthorizationDiagnosticsSink } from "./mind-authorization.ts";
|
||||||
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
|
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
|
||||||
import type { SummaryAuthorizationDiagnosticsSink } from "./summary-authorization.ts";
|
import { createServerRuntime } from "./runtime.ts";
|
||||||
|
|
||||||
const entryPath = process.argv[1];
|
const entryPath = process.argv[1];
|
||||||
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
|
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
|
||||||
@@ -15,28 +14,23 @@ const isMainModule = entryPath !== undefined && import.meta.url === pathToFileUR
|
|||||||
export const startServer = async (
|
export const startServer = async (
|
||||||
onOneTalkDiagnostic?: OneTalkDiagnosticsSink,
|
onOneTalkDiagnostic?: OneTalkDiagnosticsSink,
|
||||||
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink,
|
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink,
|
||||||
onSummaryAuthorizationDiagnostic: SummaryAuthorizationDiagnosticsSink = (event) => {
|
|
||||||
console.error(JSON.stringify(event));
|
|
||||||
},
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const config = loadConfig(readServerEnvironment());
|
const config = loadConfig(readServerEnvironment());
|
||||||
const app = createApp(config, {
|
const runtime = createServerRuntime(config, {
|
||||||
onOneTalkDiagnostic,
|
onOneTalkDiagnostic,
|
||||||
onMindAuthorizationDiagnostic,
|
onMindAuthorizationDiagnostic,
|
||||||
onSummaryAuthorizationDiagnostic,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = async (): Promise<void> => {
|
const shutdown = async (): Promise<void> => {
|
||||||
await app.close();
|
await runtime.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
process.once("SIGINT", shutdown);
|
process.once("SIGINT", shutdown);
|
||||||
process.once("SIGTERM", shutdown);
|
process.once("SIGTERM", shutdown);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ host: config.host, port: config.port });
|
await runtime.listen();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
await app.close();
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 提供仅供 Docker 内部网络访问的摘要历史 HTTP 边界
|
||||||
|
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import {
|
||||||
|
ONETALK_HISTORY_ROUTE,
|
||||||
|
ONETALK_PROTOCOL_VERSION,
|
||||||
|
} from "@trade-message-center/onetalk-contract";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ONETALK_SUMMARY_READ_PURPOSE,
|
||||||
|
type CenterMessage,
|
||||||
|
type OneTalkReadService,
|
||||||
|
} from "../onetalk/index.ts";
|
||||||
|
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
|
||||||
|
import { replyWithHistoryRead, sendError, type BrightHistoryQuery } from "./onetalk.ts";
|
||||||
|
|
||||||
|
type SummaryHistoryParams = {
|
||||||
|
channelAccountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InternalSummaryHistoryResponse = {
|
||||||
|
conversationId: string;
|
||||||
|
messages: CenterMessage[];
|
||||||
|
page: {
|
||||||
|
hasMore: boolean;
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InternalSummaryRouteOptions = {
|
||||||
|
readService: OneTalkReadService;
|
||||||
|
cutoverPolicy: OneTalkCutoverPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 注册不含浏览器授权或 CORS 的内部摘要 history 路由。 */
|
||||||
|
export const installInternalSummaryRoute = (
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: InternalSummaryRouteOptions,
|
||||||
|
): void => {
|
||||||
|
const requestEpochs = new WeakMap<FastifyRequest, number>();
|
||||||
|
const requestIsAdmitted = (request: FastifyRequest): boolean => {
|
||||||
|
const epoch = requestEpochs.get(request);
|
||||||
|
return (
|
||||||
|
epoch !== undefined &&
|
||||||
|
options.cutoverPolicy.isCurrent(epoch) &&
|
||||||
|
options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const internalAdmissionGuard = async (
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
|
||||||
|
return void sendError(reply, 503, "authorization_unavailable");
|
||||||
|
}
|
||||||
|
requestEpochs.set(request, options.cutoverPolicy.capture());
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get<{ Params: SummaryHistoryParams; Querystring: BrightHistoryQuery }>(
|
||||||
|
ONETALK_HISTORY_ROUTE,
|
||||||
|
{ preHandler: internalAdmissionGuard },
|
||||||
|
async (request, reply) => {
|
||||||
|
reply.header("cache-control", "no-store");
|
||||||
|
if (!requestIsAdmitted(request)) {
|
||||||
|
return sendError(reply, 503, "authorization_unavailable");
|
||||||
|
}
|
||||||
|
return replyWithHistoryRead(reply, {
|
||||||
|
readService: options.readService,
|
||||||
|
scope: { channelAccountId: request.params.channelAccountId },
|
||||||
|
conversationId: request.params.conversationId,
|
||||||
|
query: request.query,
|
||||||
|
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
||||||
|
isAdmitted: () => requestIsAdmitted(request),
|
||||||
|
toResponse: (result) =>
|
||||||
|
({
|
||||||
|
conversationId: result.conversationId,
|
||||||
|
messages: result.messages,
|
||||||
|
page: result.page,
|
||||||
|
}) satisfies InternalSummaryHistoryResponse,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+94
-265
@@ -6,34 +6,27 @@ import {
|
|||||||
ONETALK_CONVERSATION_ROUTE,
|
ONETALK_CONVERSATION_ROUTE,
|
||||||
ONETALK_HISTORY_ROUTE,
|
ONETALK_HISTORY_ROUTE,
|
||||||
ONETALK_PROTOCOL_VERSION,
|
ONETALK_PROTOCOL_VERSION,
|
||||||
ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
import type {
|
import type {
|
||||||
OneTalkAuthorizationDecision,
|
OneTalkAuthorizationDecision,
|
||||||
OneTalkAuthorizationReader,
|
OneTalkAuthorizationReader,
|
||||||
OneTalkMindScope,
|
OneTalkMindScope,
|
||||||
OneTalkSummaryReadScope,
|
|
||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OneTalkDatabaseError,
|
OneTalkDatabaseError,
|
||||||
type CenterConversation,
|
type CenterConversation,
|
||||||
type CenterMessage,
|
type CenterMessage,
|
||||||
|
type OneTalkHistoryReadPurpose,
|
||||||
type OneTalkReadService,
|
type OneTalkReadService,
|
||||||
} from "../onetalk/index.ts";
|
} from "../onetalk/index.ts";
|
||||||
import type { OneTalkConnectionRegistry } from "../websocket/registry.ts";
|
import type { OneTalkConnectionRegistry } from "../websocket/registry.ts";
|
||||||
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
|
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
|
||||||
import {
|
|
||||||
reportSummaryAuthorizationDiagnostic,
|
|
||||||
type SummaryAuthorizationDiagnosticsSink,
|
|
||||||
type SummaryAuthorizationReader,
|
|
||||||
} from "../summary-authorization.ts";
|
|
||||||
|
|
||||||
const SUMMARY_PURPOSE = ONETALK_SUMMARY_READ_PURPOSE;
|
|
||||||
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
|
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
|
||||||
const MINIMUM_PAGE_LIMIT = 1;
|
const MINIMUM_PAGE_LIMIT = 1;
|
||||||
const MAXIMUM_PAGE_LIMIT = 100;
|
const MAXIMUM_PAGE_LIMIT = 100;
|
||||||
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type", "x-mind-purpose"]);
|
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type"]);
|
||||||
const CORS_ALLOWED_REQUEST_METHOD = "GET";
|
const CORS_ALLOWED_REQUEST_METHOD = "GET";
|
||||||
|
|
||||||
type AccountParams = {
|
type AccountParams = {
|
||||||
@@ -99,7 +92,7 @@ export type BrightConversationResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BrightHistoryResponse = {
|
export type BrightHistoryResponse = {
|
||||||
scope: OneTalkMindScope | OneTalkSummaryReadScope;
|
scope: OneTalkMindScope;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messages: CenterMessage[];
|
messages: CenterMessage[];
|
||||||
page: {
|
page: {
|
||||||
@@ -114,8 +107,6 @@ export type BrightReadRouteOptions = {
|
|||||||
registry?: OneTalkConnectionRegistry;
|
registry?: OneTalkConnectionRegistry;
|
||||||
mindPageOrigin?: string;
|
mindPageOrigin?: string;
|
||||||
cutoverPolicy: OneTalkCutoverPolicy;
|
cutoverPolicy: OneTalkCutoverPolicy;
|
||||||
summaryAuthorization: SummaryAuthorizationReader;
|
|
||||||
onSummaryAuthorizationDiagnostic?: SummaryAuthorizationDiagnosticsSink;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthorizationResult =
|
type AuthorizationResult =
|
||||||
@@ -136,10 +127,6 @@ type AuthorizedReadScopeResult =
|
|||||||
| { ok: true; scope: OneTalkMindScope }
|
| { ok: true; scope: OneTalkMindScope }
|
||||||
| { ok: false; response: FastifyReply };
|
| { ok: false; response: FastifyReply };
|
||||||
|
|
||||||
type AuthorizedSummaryReadScopeResult =
|
|
||||||
| { ok: true; scope: OneTalkSummaryReadScope }
|
|
||||||
| { ok: false; response: FastifyReply };
|
|
||||||
|
|
||||||
const authorizationCodeFor = (decision: OneTalkAuthorizationDecision): AuthorizationResult => {
|
const authorizationCodeFor = (decision: OneTalkAuthorizationDecision): AuthorizationResult => {
|
||||||
if (decision.allowed) return { ok: true, scope: decision.mindScope };
|
if (decision.allowed) return { ok: true, scope: decision.mindScope };
|
||||||
const statusCode =
|
const statusCode =
|
||||||
@@ -182,7 +169,7 @@ const authorizeRead = async (
|
|||||||
return { ok: true, scope: decision.mindScope };
|
return { ok: true, scope: decision.mindScope };
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendError = (
|
export const sendError = (
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
statusCode: number,
|
statusCode: number,
|
||||||
code: BrightReadErrorCode,
|
code: BrightReadErrorCode,
|
||||||
@@ -204,61 +191,6 @@ const authorizeRequestScope = async (
|
|||||||
return { ok: true, scope: authorizationResult.scope };
|
return { ok: true, scope: authorizationResult.scope };
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSummaryReadAttempt = (request: FastifyRequest): boolean =>
|
|
||||||
request.headers.authorization !== undefined ||
|
|
||||||
request.headers["x-mind-workspace-id"] !== undefined;
|
|
||||||
|
|
||||||
const summaryWorkspaceIdFor = (request: FastifyRequest): string | null => {
|
|
||||||
const value = request.headers["x-mind-workspace-id"];
|
|
||||||
return typeof value === "string" && value.length > 0 && value === value.trim() ? value : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const authorizeSummaryRequestScope = async (
|
|
||||||
request: FastifyRequest,
|
|
||||||
reply: FastifyReply,
|
|
||||||
authorization: SummaryAuthorizationReader,
|
|
||||||
channelAccountId: string,
|
|
||||||
conversationId: string,
|
|
||||||
): Promise<AuthorizedSummaryReadScopeResult> => {
|
|
||||||
const workspaceId = summaryWorkspaceIdFor(request);
|
|
||||||
if (request.headers["x-mind-purpose"] !== SUMMARY_PURPOSE || workspaceId === null) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: sendError(reply, 403, "authorization_rejected"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let decision: Awaited<ReturnType<SummaryAuthorizationReader["authorize"]>>;
|
|
||||||
try {
|
|
||||||
decision = await authorization.authorize(
|
|
||||||
{
|
|
||||||
purpose: SUMMARY_PURPOSE,
|
|
||||||
workspaceId,
|
|
||||||
channelAccountId,
|
|
||||||
conversationId,
|
|
||||||
},
|
|
||||||
typeof request.headers.authorization === "string"
|
|
||||||
? request.headers.authorization
|
|
||||||
: undefined,
|
|
||||||
request.id,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: sendError(reply, 503, "authorization_unavailable"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!decision.allowed) {
|
|
||||||
const statusCode =
|
|
||||||
decision.code === "auth_required"
|
|
||||||
? 401
|
|
||||||
: decision.code === "authorization_unavailable"
|
|
||||||
? 503
|
|
||||||
: 403;
|
|
||||||
return { ok: false, response: sendError(reply, statusCode, decision.code) };
|
|
||||||
}
|
|
||||||
return { ok: true, scope: decision.authorization.scope };
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyCors = (reply: FastifyReply, request: FastifyRequest, origin?: string): void => {
|
const applyCors = (reply: FastifyReply, request: FastifyRequest, origin?: string): void => {
|
||||||
if (origin === undefined || request.headers.origin !== origin) return;
|
if (origin === undefined || request.headers.origin !== origin) return;
|
||||||
reply.header("access-control-allow-origin", origin);
|
reply.header("access-control-allow-origin", origin);
|
||||||
@@ -283,7 +215,7 @@ const pluginStatusFor = (
|
|||||||
scope: OneTalkMindScope,
|
scope: OneTalkMindScope,
|
||||||
): BrightPluginStatus => ({ status: registry?.isPluginOnline(scope) ? "online" : "offline" });
|
): BrightPluginStatus => ({ status: registry?.isPluginOnline(scope) ? "online" : "offline" });
|
||||||
|
|
||||||
const parseLimit = (value: unknown): number | undefined | null => {
|
export const parseLimit = (value: unknown): number | undefined | null => {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||||
const limit = Number(value);
|
const limit = Number(value);
|
||||||
@@ -292,14 +224,14 @@ const parseLimit = (value: unknown): number | undefined | null => {
|
|||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseOptionalSafeInteger = (value: unknown): number | undefined | null => {
|
export const parseOptionalSafeInteger = (value: unknown): number | undefined | null => {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
|
if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseOpaqueCursor = (value: unknown): string | undefined | null => {
|
export const parseOpaqueCursor = (value: unknown): string | undefined | null => {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
};
|
};
|
||||||
@@ -309,10 +241,6 @@ const parseQuery = (value: unknown): string | undefined | null => {
|
|||||||
return typeof value === "string" ? value.trim() : null;
|
return typeof value === "string" ? value.trim() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryPurposeFor = (
|
|
||||||
value: string | string[] | undefined,
|
|
||||||
): "normal" | typeof SUMMARY_PURPOSE => (value === SUMMARY_PURPOSE ? SUMMARY_PURPOSE : "normal");
|
|
||||||
|
|
||||||
const allowsCorsRequestHeaders = (value: string | string[] | undefined): boolean => {
|
const allowsCorsRequestHeaders = (value: string | string[] | undefined): boolean => {
|
||||||
if (value === undefined) return true;
|
if (value === undefined) return true;
|
||||||
if (Array.isArray(value)) return false;
|
if (Array.isArray(value)) return false;
|
||||||
@@ -330,29 +258,77 @@ const allowsCorsRequestMethod = (value: string | string[] | undefined): boolean
|
|||||||
return value === CORS_ALLOWED_REQUEST_METHOD;
|
return value === CORS_ALLOWED_REQUEST_METHOD;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReadFailure = (reply: FastifyReply, error: unknown): FastifyReply | null => {
|
export const handleReadFailure = (reply: FastifyReply, error: unknown): FastifyReply | null => {
|
||||||
if (error instanceof OneTalkDatabaseError) {
|
if (error instanceof OneTalkDatabaseError) {
|
||||||
return sendError(reply, 503, "database_unavailable");
|
return sendError(reply, 503, "database_unavailable");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const reportSummaryRead = (
|
type HistoryReadSuccess = {
|
||||||
options: BrightReadRouteOptions,
|
conversationId: string;
|
||||||
request: FastifyRequest,
|
messages: CenterMessage[];
|
||||||
startedAt: number | null,
|
page: { hasMore: boolean; nextCursor: string | null };
|
||||||
outcome: "succeeded" | "failed" | "waiting",
|
};
|
||||||
code?: BrightReadErrorCode,
|
|
||||||
): void => {
|
export type HistoryReadReplyOptions = {
|
||||||
if (startedAt === null) return;
|
readService: OneTalkReadService;
|
||||||
reportSummaryAuthorizationDiagnostic(options.onSummaryAuthorizationDiagnostic, {
|
scope: Pick<OneTalkMindScope, "channelAccountId">;
|
||||||
event: "onetalk_summary_authorization",
|
conversationId: string;
|
||||||
requestId: request.id,
|
query: BrightHistoryQuery;
|
||||||
stage: "read",
|
purpose: OneTalkHistoryReadPurpose;
|
||||||
outcome,
|
isAdmitted: () => boolean;
|
||||||
...(code === undefined ? {} : { code }),
|
toResponse: (result: HistoryReadSuccess) => unknown;
|
||||||
durationMs: Date.now() - startedAt,
|
};
|
||||||
});
|
|
||||||
|
/** 执行历史读取并映射共享的参数、分页与稳定错误语义。 */
|
||||||
|
export const replyWithHistoryRead = async (
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: HistoryReadReplyOptions,
|
||||||
|
): Promise<FastifyReply> => {
|
||||||
|
const fromSentAtMs = parseOptionalSafeInteger(options.query.fromSentAtMs);
|
||||||
|
const toSentAtMs = parseOptionalSafeInteger(options.query.toSentAtMs);
|
||||||
|
const limit = parseLimit(options.query.limit);
|
||||||
|
const cursor = parseOpaqueCursor(options.query.cursor);
|
||||||
|
if (fromSentAtMs === null || toSentAtMs === null) {
|
||||||
|
return sendError(reply, 400, "invalid_time_range");
|
||||||
|
}
|
||||||
|
if (fromSentAtMs !== undefined && toSentAtMs !== undefined && fromSentAtMs >= toSentAtMs) {
|
||||||
|
return sendError(reply, 400, "invalid_time_range");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.purpose === "communication_summary_read" &&
|
||||||
|
(fromSentAtMs === undefined || toSentAtMs === undefined)
|
||||||
|
) {
|
||||||
|
return sendError(reply, 400, "invalid_time_range");
|
||||||
|
}
|
||||||
|
if (limit === null) return sendError(reply, 400, "invalid_limit");
|
||||||
|
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await options.readService.readHistory({
|
||||||
|
scope: options.scope,
|
||||||
|
conversationId: options.conversationId,
|
||||||
|
...(fromSentAtMs === undefined ? {} : { fromSentAtMs }),
|
||||||
|
...(toSentAtMs === undefined ? {} : { toSentAtMs }),
|
||||||
|
...(limit === undefined ? {} : { limit }),
|
||||||
|
...(cursor === undefined ? {} : { cursor }),
|
||||||
|
purpose: options.purpose,
|
||||||
|
});
|
||||||
|
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
|
||||||
|
if (result.status === "not_found") return sendError(reply, 404, "conversation_not_found");
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
if (result.reason === "history_incomplete") {
|
||||||
|
reply.header("retry-after", HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS);
|
||||||
|
return sendError(reply, 503, result.reason);
|
||||||
|
}
|
||||||
|
return sendError(reply, 400, result.reason);
|
||||||
|
}
|
||||||
|
return reply.send(options.toResponse(result));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
|
||||||
|
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
|
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
|
||||||
@@ -374,7 +350,6 @@ export const installOneTalkReadRoutes = (
|
|||||||
return void sendError(reply, 503, "authorization_unavailable");
|
return void sendError(reply, 503, "authorization_unavailable");
|
||||||
}
|
}
|
||||||
requestEpochs.set(request, options.cutoverPolicy.capture());
|
requestEpochs.set(request, options.cutoverPolicy.capture());
|
||||||
if (hasSummaryReadAttempt(request)) return;
|
|
||||||
if (rejectUnexpectedOrigin(request, reply, options.mindPageOrigin)) return;
|
if (rejectUnexpectedOrigin(request, reply, options.mindPageOrigin)) return;
|
||||||
applyCors(reply, request, options.mindPageOrigin);
|
applyCors(reply, request, options.mindPageOrigin);
|
||||||
};
|
};
|
||||||
@@ -394,15 +369,13 @@ export const installOneTalkReadRoutes = (
|
|||||||
reply.header("access-control-allow-credentials", "true");
|
reply.header("access-control-allow-credentials", "true");
|
||||||
reply.header("vary", "Origin");
|
reply.header("vary", "Origin");
|
||||||
reply.header("access-control-allow-methods", "GET,OPTIONS");
|
reply.header("access-control-allow-methods", "GET,OPTIONS");
|
||||||
reply.header("access-control-allow-headers", "content-type, x-mind-purpose");
|
reply.header("access-control-allow-headers", "content-type");
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
app.get<{ Params: AccountParams; Querystring: BrightConversationListQuery }>(
|
app.get<{ Params: AccountParams; Querystring: BrightConversationListQuery }>(
|
||||||
ONETALK_CONVERSATIONS_ROUTE,
|
ONETALK_CONVERSATIONS_ROUTE,
|
||||||
{ preHandler: mindOriginGuard },
|
{ preHandler: mindOriginGuard },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
if (hasSummaryReadAttempt(request))
|
|
||||||
return sendError(reply, 403, "authorization_rejected");
|
|
||||||
const authorizedScope = await authorizeRequestScope(
|
const authorizedScope = await authorizeRequestScope(
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
@@ -448,8 +421,6 @@ export const installOneTalkReadRoutes = (
|
|||||||
ONETALK_CONVERSATION_ROUTE,
|
ONETALK_CONVERSATION_ROUTE,
|
||||||
{ preHandler: mindOriginGuard },
|
{ preHandler: mindOriginGuard },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
if (hasSummaryReadAttempt(request))
|
|
||||||
return sendError(reply, 403, "authorization_rejected");
|
|
||||||
const authorizedScope = await authorizeRequestScope(
|
const authorizedScope = await authorizeRequestScope(
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
@@ -487,172 +458,30 @@ export const installOneTalkReadRoutes = (
|
|||||||
ONETALK_HISTORY_ROUTE,
|
ONETALK_HISTORY_ROUTE,
|
||||||
{ preHandler: mindOriginGuard },
|
{ preHandler: mindOriginGuard },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const summaryAttempt = hasSummaryReadAttempt(request);
|
const authorizedScope = await authorizeRequestScope(
|
||||||
if (summaryAttempt) reply.header("cache-control", "no-store");
|
request,
|
||||||
const authorizedScope = summaryAttempt
|
reply,
|
||||||
? await authorizeSummaryRequestScope(
|
options.authorization,
|
||||||
request,
|
request.params.channelAccountId,
|
||||||
reply,
|
);
|
||||||
options.summaryAuthorization,
|
|
||||||
request.params.channelAccountId,
|
|
||||||
request.params.conversationId,
|
|
||||||
)
|
|
||||||
: await authorizeRequestScope(
|
|
||||||
request,
|
|
||||||
reply,
|
|
||||||
options.authorization,
|
|
||||||
request.params.channelAccountId,
|
|
||||||
);
|
|
||||||
if (!authorizedScope.ok) return authorizedScope.response;
|
if (!authorizedScope.ok) return authorizedScope.response;
|
||||||
if (!requestIsAdmitted(request))
|
if (!requestIsAdmitted(request))
|
||||||
return sendError(reply, 503, "authorization_unavailable");
|
return sendError(reply, 503, "authorization_unavailable");
|
||||||
|
return replyWithHistoryRead(reply, {
|
||||||
const summaryReadStartedAt = summaryAttempt ? Date.now() : null;
|
readService: options.readService,
|
||||||
|
scope: authorizedScope.scope,
|
||||||
const fromSentAtMs = parseOptionalSafeInteger(request.query.fromSentAtMs);
|
conversationId: request.params.conversationId,
|
||||||
const toSentAtMs = parseOptionalSafeInteger(request.query.toSentAtMs);
|
query: request.query,
|
||||||
const limit = parseLimit(request.query.limit);
|
purpose: "normal",
|
||||||
const cursor = parseOpaqueCursor(request.query.cursor);
|
isAdmitted: () => requestIsAdmitted(request),
|
||||||
const purpose = summaryAttempt
|
toResponse: (result) =>
|
||||||
? SUMMARY_PURPOSE
|
({
|
||||||
: summaryPurposeFor(request.headers["x-mind-purpose"]);
|
scope: authorizedScope.scope,
|
||||||
if (fromSentAtMs === null || toSentAtMs === null) {
|
conversationId: result.conversationId,
|
||||||
reportSummaryRead(
|
messages: result.messages,
|
||||||
options,
|
page: result.page,
|
||||||
request,
|
}) satisfies BrightHistoryResponse,
|
||||||
summaryReadStartedAt,
|
});
|
||||||
"failed",
|
|
||||||
"invalid_time_range",
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, "invalid_time_range");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
fromSentAtMs !== undefined &&
|
|
||||||
toSentAtMs !== undefined &&
|
|
||||||
fromSentAtMs >= toSentAtMs
|
|
||||||
) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"invalid_time_range",
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, "invalid_time_range");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
purpose === SUMMARY_PURPOSE &&
|
|
||||||
(fromSentAtMs === undefined || toSentAtMs === undefined)
|
|
||||||
) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"invalid_time_range",
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, "invalid_time_range");
|
|
||||||
}
|
|
||||||
if (limit === null) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"invalid_limit",
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, "invalid_limit");
|
|
||||||
}
|
|
||||||
if (cursor === null) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"invalid_cursor",
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, "invalid_cursor");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await options.readService.readHistory({
|
|
||||||
scope: authorizedScope.scope,
|
|
||||||
conversationId: request.params.conversationId,
|
|
||||||
...(fromSentAtMs === undefined ? {} : { fromSentAtMs }),
|
|
||||||
...(toSentAtMs === undefined ? {} : { toSentAtMs }),
|
|
||||||
...(limit === undefined ? {} : { limit }),
|
|
||||||
...(cursor === undefined ? {} : { cursor }),
|
|
||||||
purpose,
|
|
||||||
});
|
|
||||||
if (!requestIsAdmitted(request)) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"authorization_unavailable",
|
|
||||||
);
|
|
||||||
return sendError(reply, 503, "authorization_unavailable");
|
|
||||||
}
|
|
||||||
if (result.status === "not_found") {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"conversation_not_found",
|
|
||||||
);
|
|
||||||
return sendError(reply, 404, "conversation_not_found");
|
|
||||||
}
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
if (result.reason === "history_incomplete") {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"waiting",
|
|
||||||
result.reason,
|
|
||||||
);
|
|
||||||
reply.header("retry-after", HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS);
|
|
||||||
return sendError(reply, 503, result.reason);
|
|
||||||
}
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
result.reason,
|
|
||||||
);
|
|
||||||
return sendError(reply, 400, result.reason);
|
|
||||||
}
|
|
||||||
reportSummaryRead(options, request, summaryReadStartedAt, "succeeded");
|
|
||||||
return reply.send({
|
|
||||||
scope: authorizedScope.scope,
|
|
||||||
conversationId: result.conversationId,
|
|
||||||
messages: result.messages,
|
|
||||||
page: result.page,
|
|
||||||
} satisfies BrightHistoryResponse);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!requestIsAdmitted(request)) {
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
"authorization_unavailable",
|
|
||||||
);
|
|
||||||
return sendError(reply, 503, "authorization_unavailable");
|
|
||||||
}
|
|
||||||
const failure = handleReadFailure(reply, error);
|
|
||||||
reportSummaryRead(
|
|
||||||
options,
|
|
||||||
request,
|
|
||||||
summaryReadStartedAt,
|
|
||||||
"failed",
|
|
||||||
failure ? "database_unavailable" : "internal_error",
|
|
||||||
);
|
|
||||||
return failure ?? sendError(reply, 500, "internal_error");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type {
|
|||||||
OneTalkConversationReadResult,
|
OneTalkConversationReadResult,
|
||||||
OneTalkHistoryReadCursor,
|
OneTalkHistoryReadCursor,
|
||||||
OneTalkHistoryReadInput,
|
OneTalkHistoryReadInput,
|
||||||
|
OneTalkHistoryReadPurpose,
|
||||||
OneTalkHistoryReadResult,
|
OneTalkHistoryReadResult,
|
||||||
OneTalkHistoryWindow,
|
OneTalkHistoryWindow,
|
||||||
OneTalkListCursor,
|
OneTalkListCursor,
|
||||||
@@ -60,3 +61,4 @@ export type {
|
|||||||
OneTalkReadService,
|
OneTalkReadService,
|
||||||
OneTalkReadServiceDependencies,
|
OneTalkReadServiceDependencies,
|
||||||
} from "./read-model.ts";
|
} from "./read-model.ts";
|
||||||
|
export { ONETALK_SUMMARY_READ_PURPOSE } from "./read-model.ts";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
|
|
||||||
export const ONETALK_READ_DEFAULT_LIMIT = 50;
|
export const ONETALK_READ_DEFAULT_LIMIT = 50;
|
||||||
export const ONETALK_READ_MAX_LIMIT = 100;
|
export const ONETALK_READ_MAX_LIMIT = 100;
|
||||||
|
export const ONETALK_SUMMARY_READ_PURPOSE = "communication_summary_read" as const;
|
||||||
|
export type OneTalkHistoryReadPurpose = "normal" | typeof ONETALK_SUMMARY_READ_PURPOSE;
|
||||||
|
|
||||||
export type CenterConversation = {
|
export type CenterConversation = {
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
@@ -139,7 +141,7 @@ export type OneTalkHistoryReadInput = {
|
|||||||
toSentAtMs?: number | null;
|
toSentAtMs?: number | null;
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
purpose?: "normal" | "communication_summary_read";
|
purpose?: OneTalkHistoryReadPurpose;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 每页消息按旧到新返回;nextCursor 继续读取当前页之前的更旧消息。 */
|
/** 每页消息按旧到新返回;nextCursor 继续读取当前页之前的更旧消息。 */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ONETALK_READ_DEFAULT_LIMIT,
|
ONETALK_READ_DEFAULT_LIMIT,
|
||||||
ONETALK_READ_MAX_LIMIT,
|
ONETALK_READ_MAX_LIMIT,
|
||||||
|
ONETALK_SUMMARY_READ_PURPOSE,
|
||||||
type OneTalkConversationListInput,
|
type OneTalkConversationListInput,
|
||||||
type OneTalkConversationListResult,
|
type OneTalkConversationListResult,
|
||||||
type OneTalkConversationReadInput,
|
type OneTalkConversationReadInput,
|
||||||
@@ -181,7 +182,7 @@ export const createOneTalkReadService = (
|
|||||||
asOf,
|
asOf,
|
||||||
});
|
});
|
||||||
if (!conversation) return { status: "not_found" };
|
if (!conversation) return { status: "not_found" };
|
||||||
if (input.purpose === "communication_summary_read" && !conversation.historyComplete) {
|
if (input.purpose === ONETALK_SUMMARY_READ_PURPOSE && !conversation.historyComplete) {
|
||||||
return { status: "rejected", reason: "history_incomplete" };
|
return { status: "rejected", reason: "history_incomplete" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 组合 public 与内部摘要两个 Fastify 监听实例
|
||||||
|
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
import { createApp, type AppDependencies } from "./app.ts";
|
||||||
|
import type { ServerConfig } from "./config.ts";
|
||||||
|
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
|
||||||
|
import { createDatabase, type DatabaseConnection } from "./database/index.ts";
|
||||||
|
import { installInternalSummaryRoute } from "./http/onetalk-summary.ts";
|
||||||
|
import { createOneTalkReadRepository, createOneTalkReadService } from "./onetalk/index.ts";
|
||||||
|
import type { OneTalkReadService } from "./onetalk/index.ts";
|
||||||
|
|
||||||
|
export const INTERNAL_SUMMARY_HOST = "0.0.0.0";
|
||||||
|
export const INTERNAL_SUMMARY_PORT = 7777;
|
||||||
|
|
||||||
|
export type ServerRuntime = {
|
||||||
|
publicApp: FastifyInstance;
|
||||||
|
internalSummaryApp: FastifyInstance;
|
||||||
|
listen: () => Promise<void>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerRuntimeDependencies = AppDependencies & {
|
||||||
|
summaryHost?: string;
|
||||||
|
summaryPort?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 创建共享读取事实与独立网络边界的双监听运行时。 */
|
||||||
|
export const createServerRuntime = (
|
||||||
|
config: ServerConfig,
|
||||||
|
dependencies: ServerRuntimeDependencies = {},
|
||||||
|
): ServerRuntime => {
|
||||||
|
const database = dependencies.database ?? createDatabase(config.databaseUrl);
|
||||||
|
const readService =
|
||||||
|
dependencies.readService ??
|
||||||
|
createOneTalkReadService(createOneTalkReadRepository(database.db));
|
||||||
|
const cutoverPolicy = dependencies.cutoverPolicy ?? createOneTalkCutoverPolicy();
|
||||||
|
const publicApp = createApp(config, {
|
||||||
|
...dependencies,
|
||||||
|
database,
|
||||||
|
readService,
|
||||||
|
cutoverPolicy,
|
||||||
|
});
|
||||||
|
const internalSummaryApp = Fastify({ logger: false });
|
||||||
|
installInternalSummaryRoute(internalSummaryApp, { readService, cutoverPolicy });
|
||||||
|
const summaryHost = dependencies.summaryHost ?? INTERNAL_SUMMARY_HOST;
|
||||||
|
const summaryPort = dependencies.summaryPort ?? INTERNAL_SUMMARY_PORT;
|
||||||
|
|
||||||
|
const close = async (): Promise<void> => {
|
||||||
|
await internalSummaryApp.close();
|
||||||
|
await publicApp.close();
|
||||||
|
};
|
||||||
|
const listen = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await publicApp.listen({ host: config.host, port: config.port });
|
||||||
|
await internalSummaryApp.listen({ host: summaryHost, port: summaryPort });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { publicApp, internalSummaryApp, listen, close };
|
||||||
|
};
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
// 调用 Mind 纪要授权并验证服务凭据
|
|
||||||
|
|
||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
|
||||||
|
|
||||||
import {
|
|
||||||
decodeOneTalkSummaryReadAuthorization,
|
|
||||||
ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
type OneTalkSummaryReadAuthorization,
|
|
||||||
type OneTalkSummaryReadAuthorizationRequest,
|
|
||||||
type OneTalkSummaryReadScope,
|
|
||||||
} from "@trade-message-center/onetalk-contract";
|
|
||||||
|
|
||||||
import { readSummaryReadBearerToken } from "./summary-credential.ts";
|
|
||||||
|
|
||||||
export const MIND_SUMMARY_AUTHORIZATION_PATH = "/internal/bright/onetalk/authorize-summary";
|
|
||||||
|
|
||||||
export type SummaryAuthorizationCode =
|
|
||||||
| "auth_required"
|
|
||||||
| "authorization_rejected"
|
|
||||||
| "authorization_unavailable"
|
|
||||||
| "scope_mismatch";
|
|
||||||
|
|
||||||
export type SummaryAuthorizationDecision =
|
|
||||||
| { allowed: true; authorization: OneTalkSummaryReadAuthorization }
|
|
||||||
| { allowed: false; code: SummaryAuthorizationCode };
|
|
||||||
|
|
||||||
type SummaryAuthorizationRejected = Extract<SummaryAuthorizationDecision, { allowed: false }>;
|
|
||||||
|
|
||||||
export type SummaryAuthorizationDiagnostic = {
|
|
||||||
event: "onetalk_summary_authorization";
|
|
||||||
requestId: string;
|
|
||||||
stage: "credential" | "callback" | "read";
|
|
||||||
outcome:
|
|
||||||
| "request_started"
|
|
||||||
| "allowed"
|
|
||||||
| "rejected"
|
|
||||||
| "unavailable"
|
|
||||||
| "succeeded"
|
|
||||||
| "failed"
|
|
||||||
| "waiting";
|
|
||||||
code?: string;
|
|
||||||
durationMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SummaryAuthorizationDiagnosticsSink = (event: SummaryAuthorizationDiagnostic) => void;
|
|
||||||
|
|
||||||
export type SummaryAuthorizationClientConfig = {
|
|
||||||
baseUrl: string;
|
|
||||||
token?: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
fetch?: typeof fetch;
|
|
||||||
onDiagnostic?: SummaryAuthorizationDiagnosticsSink;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SummaryAuthorizationReader = {
|
|
||||||
authorize: (
|
|
||||||
request: OneTalkSummaryReadAuthorizationRequest,
|
|
||||||
authorizationHeader: string | undefined,
|
|
||||||
requestId: string,
|
|
||||||
) => Promise<SummaryAuthorizationDecision>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reportSummaryAuthorizationDiagnostic = (
|
|
||||||
sink: SummaryAuthorizationDiagnosticsSink | undefined,
|
|
||||||
event: SummaryAuthorizationDiagnostic,
|
|
||||||
): void => {
|
|
||||||
try {
|
|
||||||
sink?.(event);
|
|
||||||
} catch {
|
|
||||||
// Diagnostics are observational and must never change authorization behavior.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const digest = (value: string): Buffer => createHash("sha256").update(value).digest();
|
|
||||||
|
|
||||||
const hasExpectedCredential = (authorizationHeader: string | undefined, token: string): boolean => {
|
|
||||||
const candidate = readSummaryReadBearerToken(authorizationHeader);
|
|
||||||
return candidate !== null && timingSafeEqual(digest(token), digest(candidate));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sameScope = (left: OneTalkSummaryReadScope, right: OneTalkSummaryReadScope): boolean =>
|
|
||||||
left.workspaceId === right.workspaceId &&
|
|
||||||
left.channelAccountId === right.channelAccountId &&
|
|
||||||
left.conversationId === right.conversationId;
|
|
||||||
|
|
||||||
const rejectionFor = (
|
|
||||||
status: number,
|
|
||||||
code: string | undefined,
|
|
||||||
): SummaryAuthorizationRejected | null => {
|
|
||||||
if (status === 401 && code === "auth_required") {
|
|
||||||
return { allowed: false, code: "auth_required" };
|
|
||||||
}
|
|
||||||
if (status === 403 && code === "scope_forbidden") {
|
|
||||||
return { allowed: false, code: "scope_mismatch" };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(status === 403 && code === "summary_workspace_disabled") ||
|
|
||||||
(status === 400 && code === "invalid_request")
|
|
||||||
) {
|
|
||||||
return { allowed: false, code: "authorization_rejected" };
|
|
||||||
}
|
|
||||||
if (status === 503 && code === "authorization_unavailable") {
|
|
||||||
return { allowed: false, code: "authorization_unavailable" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const unavailable = (): SummaryAuthorizationRejected => ({
|
|
||||||
allowed: false,
|
|
||||||
code: "authorization_unavailable",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 创建缺少专用配置时明确拒绝的 summary reader。 */
|
|
||||||
export const createUnavailableSummaryAuthorizationReader = (
|
|
||||||
diagnostics?: SummaryAuthorizationDiagnosticsSink,
|
|
||||||
): SummaryAuthorizationReader => ({
|
|
||||||
authorize: async (_request, _authorizationHeader, requestId) => {
|
|
||||||
reportSummaryAuthorizationDiagnostic(diagnostics, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "credential",
|
|
||||||
outcome: "unavailable",
|
|
||||||
code: "authorization_unavailable",
|
|
||||||
durationMs: 0,
|
|
||||||
});
|
|
||||||
return unavailable();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 创建只用于消息历史读取的 Mind 纪要授权 reader。 */
|
|
||||||
export const createSummaryAuthorizationReader = (
|
|
||||||
config: SummaryAuthorizationClientConfig,
|
|
||||||
): SummaryAuthorizationReader => {
|
|
||||||
const fetchImplementation = config.fetch ?? fetch;
|
|
||||||
const authorizationUrl = new URL(MIND_SUMMARY_AUTHORIZATION_PATH, config.baseUrl);
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorize: async (request, authorizationHeader, requestId) => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
if (config.token === undefined) {
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "credential",
|
|
||||||
outcome: "unavailable",
|
|
||||||
code: "authorization_unavailable",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return unavailable();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
authorizationHeader === undefined ||
|
|
||||||
!hasExpectedCredential(authorizationHeader, config.token)
|
|
||||||
) {
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "credential",
|
|
||||||
outcome: "rejected",
|
|
||||||
code: "auth_required",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return { allowed: false, code: "auth_required" };
|
|
||||||
}
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "callback",
|
|
||||||
outcome: "request_started",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetchImplementation(authorizationUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
authorization: authorizationHeader,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
signal: AbortSignal.timeout(config.timeoutMs),
|
|
||||||
redirect: "error",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "callback",
|
|
||||||
outcome: "unavailable",
|
|
||||||
code: "authorization_unavailable",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return unavailable();
|
|
||||||
}
|
|
||||||
let decoded: ReturnType<typeof decodeOneTalkSummaryReadAuthorization>;
|
|
||||||
try {
|
|
||||||
decoded = decodeOneTalkSummaryReadAuthorization(await response.json());
|
|
||||||
} catch {
|
|
||||||
decoded = { ok: false };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
response.status === 200 &&
|
|
||||||
decoded.ok &&
|
|
||||||
sameScope(request, decoded.authorization.scope)
|
|
||||||
) {
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "callback",
|
|
||||||
outcome: "allowed",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return { allowed: true, authorization: decoded.authorization };
|
|
||||||
}
|
|
||||||
const rejection = !decoded.ok
|
|
||||||
? rejectionFor(response.status, decoded.rejection?.code)
|
|
||||||
: null;
|
|
||||||
const decision = rejection ?? unavailable();
|
|
||||||
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
|
|
||||||
event: "onetalk_summary_authorization",
|
|
||||||
requestId,
|
|
||||||
stage: "callback",
|
|
||||||
outcome: decision.code === "authorization_unavailable" ? "unavailable" : "rejected",
|
|
||||||
code: !decoded.ok ? (decoded.rejection?.code ?? decision.code) : "scope_mismatch",
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
return decision;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// 定义后台纪要服务凭据的唯一语法规则
|
|
||||||
|
|
||||||
const MINIMUM_SUMMARY_READ_TOKEN_LENGTH = 32;
|
|
||||||
const SUMMARY_READ_TOKEN68 = /^[A-Za-z0-9._~+/-]+={0,}$/;
|
|
||||||
|
|
||||||
/** 判断配置和 Bearer 凭据共用的 ASCII token68 语法。 */
|
|
||||||
export const isSummaryReadToken = (value: unknown): value is string =>
|
|
||||||
typeof value === "string" &&
|
|
||||||
value.length >= MINIMUM_SUMMARY_READ_TOKEN_LENGTH &&
|
|
||||||
SUMMARY_READ_TOKEN68.test(value);
|
|
||||||
|
|
||||||
/** 从严格 Bearer 头提取有效的后台纪要凭据。 */
|
|
||||||
export const readSummaryReadBearerToken = (
|
|
||||||
authorizationHeader: string | undefined,
|
|
||||||
): string | null => {
|
|
||||||
const match = /^Bearer (.+)$/i.exec(authorizationHeader ?? "");
|
|
||||||
return match && isSummaryReadToken(match[1]) ? match[1] : null;
|
|
||||||
};
|
|
||||||
@@ -6,6 +6,8 @@ import test from "node:test";
|
|||||||
import { createApp } from "../src/app.ts";
|
import { createApp } from "../src/app.ts";
|
||||||
import { createDatabase, type DatabaseConnection } from "../src/database/index.ts";
|
import { createDatabase, type DatabaseConnection } from "../src/database/index.ts";
|
||||||
import { loadConfig } from "../src/config.ts";
|
import { loadConfig } from "../src/config.ts";
|
||||||
|
import { createServerRuntime } from "../src/runtime.ts";
|
||||||
|
import type { OneTalkReadService } from "../src/onetalk/index.ts";
|
||||||
|
|
||||||
const testConfig = {
|
const testConfig = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -46,6 +48,46 @@ test("rejects incomplete startup configuration without exposing values", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("composes the private summary app with one shared database close owner", async () => {
|
||||||
|
const database = createDatabaseStub();
|
||||||
|
const readService: OneTalkReadService = {
|
||||||
|
listConversations: async () => ({
|
||||||
|
status: "accepted",
|
||||||
|
conversations: [],
|
||||||
|
page: { hasMore: false, nextCursor: null },
|
||||||
|
}),
|
||||||
|
readConversation: async () => ({ status: "not_found" }),
|
||||||
|
readHistory: async () => ({
|
||||||
|
status: "accepted",
|
||||||
|
conversationId: "conversation-1",
|
||||||
|
messages: [],
|
||||||
|
page: { hasMore: false, nextCursor: null },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const runtime = createServerRuntime(testConfig, {
|
||||||
|
database: database.connection,
|
||||||
|
readService,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await runtime.internalSummaryApp.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/bright/onetalk/accounts/account-1/conversations/conversation-1/messages?fromSentAtMs=1&toSentAtMs=2",
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
const body = response.json();
|
||||||
|
assert.deepEqual(body, {
|
||||||
|
conversationId: "conversation-1",
|
||||||
|
messages: [],
|
||||||
|
page: { hasMore: false, nextCursor: null },
|
||||||
|
});
|
||||||
|
assert.equal("scope" in body, false);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
assert.equal(database.getCloseCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("requires strict production Mind authorization configuration", () => {
|
test("requires strict production Mind authorization configuration", () => {
|
||||||
const base = {
|
const base = {
|
||||||
HOST: "127.0.0.1",
|
HOST: "127.0.0.1",
|
||||||
@@ -62,40 +104,6 @@ test("requires strict production Mind authorization configuration", () => {
|
|||||||
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
|
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
|
||||||
};
|
};
|
||||||
assert.equal(loadConfig(base).mindAuthorization?.timeoutMs, 3000);
|
assert.equal(loadConfig(base).mindAuthorization?.timeoutMs, 3000);
|
||||||
assert.equal(loadConfig(base).summaryReadAuthorization, undefined);
|
|
||||||
assert.deepEqual(
|
|
||||||
loadConfig({
|
|
||||||
...base,
|
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN:
|
|
||||||
"summary-read-token-with-at-least-thirty-two-characters",
|
|
||||||
}).summaryReadAuthorization,
|
|
||||||
{ token: "summary-read-token-with-at-least-thirty-two-characters" },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
loadConfig({
|
|
||||||
...base,
|
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: "summary-read-token-with-valid-padding===",
|
|
||||||
}).summaryReadAuthorization,
|
|
||||||
{ token: "summary-read-token-with-valid-padding===" },
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => loadConfig({ ...base, TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: "too-short" }),
|
|
||||||
/Invalid TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN/,
|
|
||||||
);
|
|
||||||
for (const invalidToken of [
|
|
||||||
"summary-read-token-with an-internal-space-and-length",
|
|
||||||
"summary-read-token-with,comma-and-length-value",
|
|
||||||
"summary-read-token-with=padding-in-the-middle-value",
|
|
||||||
]) {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
loadConfig({
|
|
||||||
...base,
|
|
||||||
TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: invalidToken,
|
|
||||||
}),
|
|
||||||
/Invalid TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN/,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert.equal(loadConfig(base).chromeExtensionDownload?.packageVersion, "1.2.3");
|
assert.equal(loadConfig(base).chromeExtensionDownload?.packageVersion, "1.2.3");
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => loadConfig({ ...base, TMC_PACKAGE_VERSION: "1.2" }),
|
() => loadConfig({ ...base, TMC_PACKAGE_VERSION: "1.2" }),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
// 验证 Bright direct 会话读取 HTTP 边界与原生联调页
|
// 验证 Bright direct 会话读取 HTTP 边界与原生联调页
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer, type IncomingMessage } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createMockAuthorizationReader,
|
createMockAuthorizationReader,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
import { createApp } from "../src/app.ts";
|
import { createApp } from "../src/app.ts";
|
||||||
import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts";
|
import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts";
|
||||||
import type { DatabaseConnection } from "../src/database/index.ts";
|
import type { DatabaseConnection } from "../src/database/index.ts";
|
||||||
|
import { installInternalSummaryRoute } from "../src/http/onetalk-summary.ts";
|
||||||
import {
|
import {
|
||||||
OneTalkDatabaseError,
|
OneTalkDatabaseError,
|
||||||
type CenterConversation,
|
type CenterConversation,
|
||||||
@@ -20,9 +20,6 @@ import {
|
|||||||
type OneTalkReadService,
|
type OneTalkReadService,
|
||||||
} from "../src/onetalk/index.ts";
|
} from "../src/onetalk/index.ts";
|
||||||
|
|
||||||
const summaryToken = "summary-read-token-with-at-least-thirty-two-characters";
|
|
||||||
const summaryWorkspaceId = "803937a7-f7d3-497d-a5ec-b99d0314669e";
|
|
||||||
|
|
||||||
const testConfig = {
|
const testConfig = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@@ -126,6 +123,15 @@ const closeApp = async (app: ReturnType<typeof createApp>): Promise<void> => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createInternalSummaryApp = (
|
||||||
|
readService: OneTalkReadService,
|
||||||
|
cutoverPolicy = createOneTalkCutoverPolicy(),
|
||||||
|
) => {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
installInternalSummaryRoute(app, { readService, cutoverPolicy });
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
test("returns the shared CenterConversation projection for list and detail", async () => {
|
test("returns the shared CenterConversation projection for list and detail", async () => {
|
||||||
const app = createApp(testConfig, {
|
const app = createApp(testConfig, {
|
||||||
database: createDatabaseStub(),
|
database: createDatabaseStub(),
|
||||||
@@ -257,7 +263,7 @@ test("returns direct read not-found without treating it as an empty conversation
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("forwards the half-open window, summary purpose, and opaque history cursor unchanged", async () => {
|
test("forwards the half-open window and opaque history cursor unchanged", async () => {
|
||||||
let received: Parameters<OneTalkReadService["readHistory"]>[0] | undefined;
|
let received: Parameters<OneTalkReadService["readHistory"]>[0] | undefined;
|
||||||
const app = createApp(testConfig, {
|
const app = createApp(testConfig, {
|
||||||
database: createDatabaseStub(),
|
database: createDatabaseStub(),
|
||||||
@@ -281,7 +287,7 @@ test("forwards the half-open window, summary purpose, and opaque history cursor
|
|||||||
url:
|
url:
|
||||||
historyUrl() +
|
historyUrl() +
|
||||||
"?fromSentAtMs=10&toSentAtMs=20&limit=1&cursor=opaque-history-cursor",
|
"?fromSentAtMs=10&toSentAtMs=20&limit=1&cursor=opaque-history-cursor",
|
||||||
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
headers: headers(),
|
||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.deepEqual(received, {
|
assert.deepEqual(received, {
|
||||||
@@ -291,7 +297,7 @@ test("forwards the half-open window, summary purpose, and opaque history cursor
|
|||||||
toSentAtMs: 20,
|
toSentAtMs: 20,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
cursor: "opaque-history-cursor",
|
cursor: "opaque-history-cursor",
|
||||||
purpose: "communication_summary_read",
|
purpose: "normal",
|
||||||
});
|
});
|
||||||
const body = response.json();
|
const body = response.json();
|
||||||
assert.deepEqual(body.page, { hasMore: true, nextCursor: "next-history-cursor" });
|
assert.deepEqual(body.page, { hasMore: true, nextCursor: "next-history-cursor" });
|
||||||
@@ -310,166 +316,92 @@ test("forwards the half-open window, summary purpose, and opaque history cursor
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows a real loopback summary callback only for the exact history scope", async () => {
|
test("serves internal summary history without credentials or a returned authorization scope", async () => {
|
||||||
let receivedHeaders: IncomingMessage["headers"] | undefined;
|
let received: Parameters<OneTalkReadService["readHistory"]>[0] | undefined;
|
||||||
let receivedBody: unknown;
|
const app = createInternalSummaryApp(
|
||||||
const diagnostics: unknown[] = [];
|
createReadService({
|
||||||
const callback = createServer((request, response) => {
|
readHistory: async (input) => {
|
||||||
const chunks: Buffer[] = [];
|
received = input;
|
||||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
return {
|
||||||
request.on("end", () => {
|
status: "accepted",
|
||||||
receivedHeaders = request.headers;
|
conversationId: conversation.conversationId,
|
||||||
receivedBody = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
messages: [message("message-1", 10)],
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
page: { hasMore: false, nextCursor: null },
|
||||||
response.end(
|
};
|
||||||
JSON.stringify({
|
|
||||||
purpose: "communication_summary_read",
|
|
||||||
scope: {
|
|
||||||
workspaceId: summaryWorkspaceId,
|
|
||||||
channelAccountId: pluginScope.channelAccountId,
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
},
|
|
||||||
permissions: ["read"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => callback.listen(0, "127.0.0.1", resolve));
|
|
||||||
const callbackPort = (callback.address() as AddressInfo).port;
|
|
||||||
const app = createApp(
|
|
||||||
{
|
|
||||||
...testConfig,
|
|
||||||
mindAuthorization: {
|
|
||||||
...testConfig.mindAuthorization,
|
|
||||||
baseUrl: `http://127.0.0.1:${callbackPort}`,
|
|
||||||
},
|
},
|
||||||
summaryReadAuthorization: { token: summaryToken },
|
}),
|
||||||
},
|
|
||||||
{
|
|
||||||
database: createDatabaseStub(),
|
|
||||||
readService: createReadService(),
|
|
||||||
onSummaryAuthorizationDiagnostic: (event) => diagnostics.push(event),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
||||||
const centerPort = (app.server.address() as AddressInfo).port;
|
|
||||||
const response = await fetch(
|
|
||||||
`http://127.0.0.1:${centerPort}${historyUrl()}?fromSentAtMs=10&toSentAtMs=20`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${summaryToken}`,
|
|
||||||
"x-mind-purpose": "communication_summary_read",
|
|
||||||
"x-mind-workspace-id": summaryWorkspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
||||||
assert.deepEqual((await response.json()).scope, {
|
|
||||||
workspaceId: summaryWorkspaceId,
|
|
||||||
channelAccountId: pluginScope.channelAccountId,
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
});
|
|
||||||
assert.deepEqual(receivedBody, {
|
|
||||||
purpose: "communication_summary_read",
|
|
||||||
workspaceId: summaryWorkspaceId,
|
|
||||||
channelAccountId: pluginScope.channelAccountId,
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
});
|
|
||||||
assert.equal(receivedHeaders?.authorization, `Bearer ${summaryToken}`);
|
|
||||||
assert.equal(receivedHeaders?.cookie, undefined);
|
|
||||||
assert.equal(JSON.stringify(diagnostics).includes("onetalk_summary_authorization"), true);
|
|
||||||
assert.equal(JSON.stringify(diagnostics).includes(summaryToken), false);
|
|
||||||
assert.equal(JSON.stringify(diagnostics).includes(summaryWorkspaceId), false);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
|
||||||
callback.close((error) => (error ? reject(error) : resolve())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not allow summary credentials to reach list, detail, or Cookie authorization", async () => {
|
|
||||||
let pageAuthorizationCalls = 0;
|
|
||||||
const app = createApp(testConfig, {
|
|
||||||
database: createDatabaseStub(),
|
|
||||||
authorization: {
|
|
||||||
authorize: async () => {
|
|
||||||
pageAuthorizationCalls += 1;
|
|
||||||
return { allowed: false, code: "auth_required" };
|
|
||||||
},
|
|
||||||
readAuthorizationVersion: async () => "unused",
|
|
||||||
},
|
|
||||||
readService: createReadService(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const url of [conversationsUrl(), conversationUrl()]) {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url,
|
|
||||||
headers: {
|
|
||||||
...headers(),
|
|
||||||
authorization: `Bearer ${summaryToken}`,
|
|
||||||
"x-mind-workspace-id": summaryWorkspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(response.statusCode, 403);
|
|
||||||
assert.deepEqual(response.json(), { error: { code: "authorization_rejected" } });
|
|
||||||
}
|
|
||||||
assert.equal(pageAuthorizationCalls, 0);
|
|
||||||
} finally {
|
|
||||||
await closeApp(app);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fails closed when the summary authorization dependency throws", async () => {
|
|
||||||
let pageAuthorizationCalls = 0;
|
|
||||||
const app = createApp(testConfig, {
|
|
||||||
database: createDatabaseStub(),
|
|
||||||
authorization: {
|
|
||||||
authorize: async () => {
|
|
||||||
pageAuthorizationCalls += 1;
|
|
||||||
return { allowed: false, code: "auth_required" };
|
|
||||||
},
|
|
||||||
readAuthorizationVersion: async () => "unused",
|
|
||||||
},
|
|
||||||
summaryAuthorization: {
|
|
||||||
authorize: async () => {
|
|
||||||
throw new Error("summary authorization dependency secret");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
readService: createReadService(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
|
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
|
||||||
headers: {
|
headers: {
|
||||||
...headers(),
|
authorization: "Bearer stale-summary-token",
|
||||||
authorization: `Bearer ${summaryToken}`,
|
cookie: "mind_session=unused",
|
||||||
"x-mind-purpose": "communication_summary_read",
|
"x-mind-purpose": "communication_summary_read",
|
||||||
"x-mind-workspace-id": summaryWorkspaceId,
|
"x-mind-workspace-id": "workspace-ignored-by-center",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 503);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.deepEqual(response.json(), { error: { code: "authorization_unavailable" } });
|
assert.equal(response.headers["cache-control"], "no-store");
|
||||||
assert.equal(response.body.includes("dependency secret"), false);
|
assert.deepEqual(received, {
|
||||||
assert.equal(pageAuthorizationCalls, 0);
|
scope: { channelAccountId: pluginScope.channelAccountId },
|
||||||
|
conversationId: conversation.conversationId,
|
||||||
|
fromSentAtMs: 10,
|
||||||
|
toSentAtMs: 20,
|
||||||
|
purpose: "communication_summary_read",
|
||||||
|
});
|
||||||
|
assert.deepEqual(response.json(), {
|
||||||
|
conversationId: conversation.conversationId,
|
||||||
|
messages: [message("message-1", 10)],
|
||||||
|
page: { hasMore: false, nextCursor: null },
|
||||||
|
});
|
||||||
|
assert.equal("scope" in response.json(), false);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public routes keep Cookie authorization when callers send retired summary headers", async () => {
|
||||||
|
let pageAuthorizationCalls = 0;
|
||||||
|
const app = createApp(testConfig, {
|
||||||
|
database: createDatabaseStub(),
|
||||||
|
authorization: {
|
||||||
|
authorize: async () => {
|
||||||
|
pageAuthorizationCalls += 1;
|
||||||
|
return { allowed: false, code: "auth_required" };
|
||||||
|
},
|
||||||
|
readAuthorizationVersion: async () => "unused",
|
||||||
|
},
|
||||||
|
readService: createReadService(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const url of [conversationsUrl(), conversationUrl(), historyUrl()]) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
...headers(),
|
||||||
|
authorization: "Bearer retired-summary-token",
|
||||||
|
"x-mind-purpose": "communication_summary_read",
|
||||||
|
"x-mind-workspace-id": "workspace-ignored-by-center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
assert.deepEqual(response.json(), { error: { code: "auth_required" } });
|
||||||
|
}
|
||||||
|
assert.equal(pageAuthorizationCalls, 3);
|
||||||
} finally {
|
} finally {
|
||||||
await closeApp(app);
|
await closeApp(app);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects summary reads unless both window endpoints are present", async () => {
|
test("rejects internal summary reads unless both window endpoints are present", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const app = createApp(testConfig, {
|
const app = createInternalSummaryApp(
|
||||||
database: createDatabaseStub(),
|
createReadService({
|
||||||
authorization: createMockAuthorizationReader([authorizationRecord]),
|
|
||||||
readService: createReadService({
|
|
||||||
readHistory: async () => {
|
readHistory: async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return {
|
return {
|
||||||
@@ -480,25 +412,77 @@ test("rejects summary reads unless both window endpoints are present", async ()
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const suffix of ["", "?fromSentAtMs=10", "?toSentAtMs=20"]) {
|
for (const suffix of ["", "?fromSentAtMs=10", "?toSentAtMs=20"]) {
|
||||||
const response = await app.inject({
|
const response = await app.inject({ method: "GET", url: historyUrl() + suffix });
|
||||||
method: "GET",
|
|
||||||
url: historyUrl() + suffix,
|
|
||||||
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
|
||||||
});
|
|
||||||
assert.equal(response.statusCode, 400);
|
assert.equal(response.statusCode, 400);
|
||||||
assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } });
|
assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } });
|
||||||
}
|
}
|
||||||
assert.equal(calls, 0);
|
assert.equal(calls, 0);
|
||||||
} finally {
|
} finally {
|
||||||
await closeApp(app);
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps message input failures, not found, and summary history gates distinct", async () => {
|
test("keeps the internal summary history gate and retry response", async () => {
|
||||||
|
const app = createInternalSummaryApp(
|
||||||
|
createReadService({
|
||||||
|
readHistory: async () => {
|
||||||
|
return { status: "rejected", reason: "history_incomplete" };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 503);
|
||||||
|
assert.equal(response.headers["retry-after"], "30");
|
||||||
|
assert.deepEqual(response.json(), { error: { code: "history_incomplete" } });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the internal listener limited to history and fenced by cutover availability", async () => {
|
||||||
|
const policy = createOneTalkCutoverPolicy();
|
||||||
|
let calls = 0;
|
||||||
|
const app = createInternalSummaryApp(
|
||||||
|
createReadService({
|
||||||
|
readHistory: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return {
|
||||||
|
status: "accepted",
|
||||||
|
conversationId: conversation.conversationId,
|
||||||
|
messages: [],
|
||||||
|
page: { hasMore: false, nextCursor: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const missingRoute = await app.inject({ method: "GET", url: conversationsUrl() });
|
||||||
|
assert.equal(missingRoute.statusCode, 404);
|
||||||
|
policy.pause();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20",
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 503);
|
||||||
|
assert.deepEqual(response.json(), { error: { code: "authorization_unavailable" } });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps public message input failures and not-found results distinct", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const app = createApp(testConfig, {
|
const app = createApp(testConfig, {
|
||||||
database: createDatabaseStub(),
|
database: createDatabaseStub(),
|
||||||
@@ -507,9 +491,6 @@ test("keeps message input failures, not found, and summary history gates distinc
|
|||||||
readHistory: async (input) => {
|
readHistory: async (input) => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (input.cursor === "not-found") return { status: "not_found" };
|
if (input.cursor === "not-found") return { status: "not_found" };
|
||||||
if (input.cursor === "history-incomplete") {
|
|
||||||
return { status: "rejected", reason: "history_incomplete" };
|
|
||||||
}
|
|
||||||
return { status: "rejected", reason: "invalid_cursor" };
|
return { status: "rejected", reason: "invalid_cursor" };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -540,15 +521,6 @@ test("keeps message input failures, not found, and summary history gates distinc
|
|||||||
});
|
});
|
||||||
assert.equal(notFound.statusCode, 404);
|
assert.equal(notFound.statusCode, 404);
|
||||||
assert.deepEqual(notFound.json(), { error: { code: "conversation_not_found" } });
|
assert.deepEqual(notFound.json(), { error: { code: "conversation_not_found" } });
|
||||||
|
|
||||||
const incomplete = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20&cursor=history-incomplete",
|
|
||||||
headers: { ...headers(), "x-mind-purpose": "communication_summary_read" },
|
|
||||||
});
|
|
||||||
assert.equal(incomplete.statusCode, 503);
|
|
||||||
assert.equal(incomplete.headers["retry-after"], "30");
|
|
||||||
assert.deepEqual(incomplete.json(), { error: { code: "history_incomplete" } });
|
|
||||||
} finally {
|
} finally {
|
||||||
await closeApp(app);
|
await closeApp(app);
|
||||||
}
|
}
|
||||||
@@ -576,7 +548,7 @@ test("rejects unknown history cursors through the accepted read service", async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preflight allows the summary header and a rejected origin cannot invoke authorization", async () => {
|
test("preflight rejects retired summary headers and rejected origins cannot invoke authorization", async () => {
|
||||||
let authorizationCalls = 0;
|
let authorizationCalls = 0;
|
||||||
const authorization = createMockAuthorizationReader([authorizationRecord]);
|
const authorization = createMockAuthorizationReader([authorizationRecord]);
|
||||||
const app = createApp(testConfig, {
|
const app = createApp(testConfig, {
|
||||||
@@ -600,7 +572,7 @@ test("preflight allows the summary header and a rejected origin cannot invoke au
|
|||||||
assert.equal(preflight.statusCode, 204);
|
assert.equal(preflight.statusCode, 204);
|
||||||
assert.equal(preflight.headers["access-control-allow-origin"], "http://mind.localhost");
|
assert.equal(preflight.headers["access-control-allow-origin"], "http://mind.localhost");
|
||||||
assert.equal(preflight.headers["access-control-allow-credentials"], "true");
|
assert.equal(preflight.headers["access-control-allow-credentials"], "true");
|
||||||
assert.match(preflight.headers["access-control-allow-headers"] ?? "", /x-mind-purpose/);
|
assert.equal(preflight.headers["access-control-allow-headers"], "content-type");
|
||||||
assert.equal(preflight.headers.vary, "Origin");
|
assert.equal(preflight.headers.vary, "Origin");
|
||||||
|
|
||||||
const allowedHeaders = await app.inject({
|
const allowedHeaders = await app.inject({
|
||||||
@@ -609,7 +581,7 @@ test("preflight allows the summary header and a rejected origin cannot invoke au
|
|||||||
headers: {
|
headers: {
|
||||||
origin: "http://mind.localhost",
|
origin: "http://mind.localhost",
|
||||||
"access-control-request-method": "GET",
|
"access-control-request-method": "GET",
|
||||||
"access-control-request-headers": "Content-Type, X-Mind-Purpose",
|
"access-control-request-headers": "Content-Type",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(allowedHeaders.statusCode, 204);
|
assert.equal(allowedHeaders.statusCode, 204);
|
||||||
@@ -629,7 +601,7 @@ test("preflight allows the summary header and a rejected origin cannot invoke au
|
|||||||
headers: {
|
headers: {
|
||||||
origin: "http://mind.localhost",
|
origin: "http://mind.localhost",
|
||||||
"access-control-request-method": "GET",
|
"access-control-request-method": "GET",
|
||||||
"access-control-request-headers": "x-unapproved-header",
|
"access-control-request-headers": "x-mind-purpose",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(disallowedHeader.statusCode, 403);
|
assert.equal(disallowedHeader.statusCode, 403);
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
// 验证纪要服务凭据和 Mind 回调的失败关闭边界
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { ONETALK_SUMMARY_READ_PURPOSE } from "@trade-message-center/onetalk-contract";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createSummaryAuthorizationReader,
|
|
||||||
MIND_SUMMARY_AUTHORIZATION_PATH,
|
|
||||||
} from "../src/summary-authorization.ts";
|
|
||||||
|
|
||||||
const token = "summary-read-token-with-at-least-thirty-two-characters";
|
|
||||||
const request = {
|
|
||||||
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
workspaceId: "803937a7-f7d3-497d-a5ec-b99d0314669e",
|
|
||||||
channelAccountId: "account-1",
|
|
||||||
conversationId: "conversation-1",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const response = (status: number, body: unknown): Response =>
|
|
||||||
({ status, json: async () => body }) as Response;
|
|
||||||
|
|
||||||
test("sends the exact summary scope and no Cookie to the fixed Mind callback", async () => {
|
|
||||||
let url = "";
|
|
||||||
let init: RequestInit | undefined;
|
|
||||||
const reader = createSummaryAuthorizationReader({
|
|
||||||
baseUrl: "https://mind.example.com",
|
|
||||||
timeoutMs: 100,
|
|
||||||
token,
|
|
||||||
fetch: async (input, requestInit) => {
|
|
||||||
url = String(input);
|
|
||||||
init = requestInit;
|
|
||||||
return response(200, {
|
|
||||||
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
scope: {
|
|
||||||
workspaceId: request.workspaceId,
|
|
||||||
channelAccountId: request.channelAccountId,
|
|
||||||
conversationId: request.conversationId,
|
|
||||||
},
|
|
||||||
permissions: ["read"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const decision = await reader.authorize(request, `Bearer ${token}`, "request-1");
|
|
||||||
assert.equal(decision.allowed, true);
|
|
||||||
assert.equal(url, "https://mind.example.com" + MIND_SUMMARY_AUTHORIZATION_PATH);
|
|
||||||
assert.deepEqual(JSON.parse(String(init?.body)), request);
|
|
||||||
assert.equal((init?.headers as Record<string, string>).cookie, undefined);
|
|
||||||
assert.equal((init?.headers as Record<string, string>).authorization, `Bearer ${token}`);
|
|
||||||
assert.equal(init?.redirect, "error");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fails closed for missing config, bad credentials, invalid response, and scope mismatch", async () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
config: { baseUrl: "https://mind.example.com", timeoutMs: 100 },
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
response: response(200, {}),
|
|
||||||
expected: "authorization_unavailable",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
||||||
authorization: "Bearer wrong-token",
|
|
||||||
response: response(200, {}),
|
|
||||||
expected: "auth_required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
response: response(200, { purpose: ONETALK_SUMMARY_READ_PURPOSE }),
|
|
||||||
expected: "authorization_unavailable",
|
|
||||||
},
|
|
||||||
...(["workspaceId", "channelAccountId", "conversationId"] as const).map((field) => ({
|
|
||||||
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
response: response(200, {
|
|
||||||
purpose: ONETALK_SUMMARY_READ_PURPOSE,
|
|
||||||
scope: {
|
|
||||||
workspaceId: field === "workspaceId" ? "other-workspace" : request.workspaceId,
|
|
||||||
channelAccountId:
|
|
||||||
field === "channelAccountId" ? "other-account" : request.channelAccountId,
|
|
||||||
conversationId:
|
|
||||||
field === "conversationId" ? "other-conversation" : request.conversationId,
|
|
||||||
},
|
|
||||||
permissions: ["read"],
|
|
||||||
}),
|
|
||||||
expected: "authorization_unavailable",
|
|
||||||
})),
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const testCase of cases) {
|
|
||||||
const reader = createSummaryAuthorizationReader({
|
|
||||||
...testCase.config,
|
|
||||||
fetch: async () => testCase.response,
|
|
||||||
});
|
|
||||||
const decision = await reader.authorize(request, testCase.authorization, "request-1");
|
|
||||||
assert.deepEqual(decision, { allowed: false, code: testCase.expected });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the configured token68 grammar before comparing Bearer credentials", async () => {
|
|
||||||
let fetchCalls = 0;
|
|
||||||
const reader = createSummaryAuthorizationReader({
|
|
||||||
baseUrl: "https://mind.example.com",
|
|
||||||
timeoutMs: 100,
|
|
||||||
token,
|
|
||||||
fetch: async () => {
|
|
||||||
fetchCalls += 1;
|
|
||||||
return response(200, {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for (const authorization of [
|
|
||||||
"Bearer summary-read-token-with an-internal-space-and-length",
|
|
||||||
"Bearer summary-read-token-with,comma-and-length-value",
|
|
||||||
"Bearer summary-read-token-with=padding-in-the-middle-value",
|
|
||||||
]) {
|
|
||||||
assert.deepEqual(await reader.authorize(request, authorization, "request-1"), {
|
|
||||||
allowed: false,
|
|
||||||
code: "auth_required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
assert.equal(fetchCalls, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fails closed for real callback timeout, redirect, invalid JSON, and 500", async () => {
|
|
||||||
let mode: "timeout" | "redirect" | "invalid-json" | "server-error" = "timeout";
|
|
||||||
const callback = createServer((_request, response) => {
|
|
||||||
if (mode === "timeout") {
|
|
||||||
setTimeout(() => response.destroy(), 50);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "redirect") {
|
|
||||||
response.writeHead(302, { location: "/redirect-target" });
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "invalid-json") {
|
|
||||||
response.writeHead(200, { "content-type": "text/html" });
|
|
||||||
response.end("<html>not-json</html>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
response.writeHead(500, { "content-type": "application/json" });
|
|
||||||
response.end(JSON.stringify({ code: "authorization_unavailable" }));
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => callback.listen(0, "127.0.0.1", resolve));
|
|
||||||
const port = (callback.address() as AddressInfo).port;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const nextMode of ["timeout", "redirect", "invalid-json", "server-error"] as const) {
|
|
||||||
mode = nextMode;
|
|
||||||
const reader = createSummaryAuthorizationReader({
|
|
||||||
baseUrl: `http://127.0.0.1:${port}`,
|
|
||||||
timeoutMs: nextMode === "timeout" ? 5 : 100,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
await reader.authorize(request, `Bearer ${token}`, `request-${nextMode}`),
|
|
||||||
{ allowed: false, code: "authorization_unavailable" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
|
||||||
callback.close((error) => (error ? reject(error) : resolve())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps only exact Mind status and rejection pairs", async () => {
|
|
||||||
const cases = [
|
|
||||||
[401, "auth_required", "auth_required"],
|
|
||||||
[403, "scope_forbidden", "scope_mismatch"],
|
|
||||||
[403, "summary_workspace_disabled", "authorization_rejected"],
|
|
||||||
[400, "invalid_request", "authorization_rejected"],
|
|
||||||
[503, "authorization_unavailable", "authorization_unavailable"],
|
|
||||||
[403, "auth_required", "authorization_unavailable"],
|
|
||||||
[200, "scope_forbidden", "authorization_unavailable"],
|
|
||||||
] as const;
|
|
||||||
for (const [status, code, expected] of cases) {
|
|
||||||
const reader = createSummaryAuthorizationReader({
|
|
||||||
baseUrl: "https://mind.example.com",
|
|
||||||
timeoutMs: 100,
|
|
||||||
token,
|
|
||||||
fetch: async () => response(status, { code }),
|
|
||||||
});
|
|
||||||
assert.deepEqual(await reader.authorize(request, `Bearer ${token}`, "request-1"), {
|
|
||||||
allowed: false,
|
|
||||||
code: expected,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -448,8 +448,8 @@ test("keeps direct WebSocket installation fail-closed by default", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not treat a summary Bearer header as a Mind WebSocket identity", async () => {
|
test("does not treat an arbitrary Bearer header as a Mind WebSocket identity", async () => {
|
||||||
const summaryToken = "summary-read-token-with-at-least-thirty-two-characters";
|
const bearerToken = "retired-internal-read-token";
|
||||||
const authorization = createMindAuthorizationReader({
|
const authorization = createMindAuthorizationReader({
|
||||||
baseUrl: "https://mind.example.com",
|
baseUrl: "https://mind.example.com",
|
||||||
timeoutMs: 100,
|
timeoutMs: 100,
|
||||||
@@ -466,8 +466,7 @@ test("does not treat a summary Bearer header as a Mind WebSocket identity", asyn
|
|||||||
const socket = await app.injectWS("/ws/mind", {
|
const socket = await app.injectWS("/ws/mind", {
|
||||||
headers: {
|
headers: {
|
||||||
origin: "http://mind.localhost",
|
origin: "http://mind.localhost",
|
||||||
authorization: `Bearer ${summaryToken}`,
|
authorization: `Bearer ${bearerToken}`,
|
||||||
"x-mind-workspace-id": "workspace-1",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,7 +477,7 @@ test("does not treat a summary Bearer header as a Mind WebSocket identity", asyn
|
|||||||
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
||||||
connectionType: "mind_page",
|
connectionType: "mind_page",
|
||||||
type: "ws.hello",
|
type: "ws.hello",
|
||||||
requestId: "summary-token-hello",
|
requestId: "bearer-token-hello",
|
||||||
scope: mindScope,
|
scope: mindScope,
|
||||||
payload: { requestedPermissions: ["read", "send"] },
|
payload: { requestedPermissions: ["read", "send"] },
|
||||||
}),
|
}),
|
||||||
@@ -487,7 +486,7 @@ test("does not treat a summary Bearer header as a Mind WebSocket identity", asyn
|
|||||||
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
||||||
connectionType: "mind_page",
|
connectionType: "mind_page",
|
||||||
type: "ws.error",
|
type: "ws.error",
|
||||||
requestId: "summary-token-hello",
|
requestId: "bearer-token-hello",
|
||||||
scope: mindScope,
|
scope: mindScope,
|
||||||
payload: { code: ONETALK_ERROR_CODES.authRequired },
|
payload: { code: ONETALK_ERROR_CODES.authRequired },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Access-Control-Allow-Credentials: true
|
|||||||
Vary: Origin
|
Vary: Origin
|
||||||
```
|
```
|
||||||
|
|
||||||
OPTIONS 允许 GET、OPTIONS 和 Content-Type、X-Mind-Purpose。若浏览器的 Access-Control-Request-Headers 包含任一其它 header(比较不区分大小写)则返回 403;不允许的 Origin 同样返回 403,且不会调用读取服务或授权读取。
|
OPTIONS 只允许 GET、OPTIONS 和 Content-Type。若浏览器的 Access-Control-Request-Headers 包含任一其它 header(比较不区分大小写)则返回 403;不允许的 Origin 同样返回 403,且不会调用读取服务或授权读取。
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Scope = {
|
type Scope = {
|
||||||
@@ -172,28 +172,9 @@ type CenterMessage =
|
|||||||
|
|
||||||
响应绝不暴露原始数字或原始顶层字段,包括原始 contentType、readStatus、messageStatus、unreadCount、messageRevision、conversationRevision、updatedAtMs、recalledAtMs、deliveryStatus、顶层 text、顶层 subject 和 attachmentSummaryText。
|
响应绝不暴露原始数字或原始顶层字段,包括原始 contentType、readStatus、messageStatus、unreadCount、messageRevision、conversationRevision、updatedAtMs、recalledAtMs、deliveryStatus、顶层 text、顶层 subject 和 attachmentSummaryText。
|
||||||
|
|
||||||
## 纪要读取门槛
|
## 内部纪要读取
|
||||||
|
|
||||||
普通页面读取可省略时间窗,并可读取已持久化的部分历史。Mind 读取纪要窗口时必须同时提供两个时间端点,并发送精确 header:
|
本页面 API 只接受 Mind Session/Cookie 授权;`Authorization`、`X-Mind-Purpose` 和 `X-Mind-Workspace-Id` 不会选择另一条读取路径。供 Mind 后台生成纪要的内部 Docker 网络接口、固定时间窗和 `history_incomplete` 规则见 [OneTalk 内部纪要读取接口](./onetalk-summary-internal-api.md)。
|
||||||
|
|
||||||
```http
|
|
||||||
X-Mind-Purpose: communication_summary_read
|
|
||||||
```
|
|
||||||
|
|
||||||
若该会话 historyComplete 为 false,Bright 返回:
|
|
||||||
|
|
||||||
```http
|
|
||||||
503 Service Unavailable
|
|
||||||
Retry-After: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: "history_incomplete";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 分页与错误
|
## 分页与错误
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# OneTalk 内部纪要读取接口
|
||||||
|
|
||||||
|
> 本文交付给 trade-mind 所有者。它描述 Center 已发布的内部 Docker listener;Mind 代码和部署改动不在本仓库。
|
||||||
|
|
||||||
|
## 前提与安全边界
|
||||||
|
|
||||||
|
Center 与 Mind 容器必须运行在同一 Docker daemon,并共同加入名为 `trade-message-center-summary` 的 Docker `internal` 网络。该网络的成员资格是唯一访问控制:加入网络的任意容器可读取任意已知 `channelAccountId + conversationId` 的摘要历史;它不提供 workspace、用户、账号或会话级授权,也不防护 Docker socket 持有者或宿主机 root。
|
||||||
|
|
||||||
|
Center 运行在仅供其公网发布与 egress 的 `trade-message-center-public` bridge 网络,并额外挂载 summary internal 网络;其它业务容器不得加入前者来访问 Center。Center 仍通过宿主机发布 public `7878`;内部 `7777` 绝不配置 Docker `--publish` 或 Compose `ports`。`EXPOSE 7777` 只是镜像元数据,不会对外发布端口。
|
||||||
|
|
||||||
|
## Mind 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=:inclusiveEpochMs&toSentAtMs=:exclusiveEpochMs&cursor=:opaqueCursor&limit=:1to100
|
||||||
|
```
|
||||||
|
|
||||||
|
- `fromSentAtMs` 与 `toSentAtMs` 必填,均为 safe integer,窗口为 `[fromSentAtMs,toSentAtMs)`,且前者必须小于后者。
|
||||||
|
- `cursor` 仅可原样回传上一页 `nextCursor`;`limit` 默认为 50,范围 1..100。
|
||||||
|
- 不发送 Cookie、`Authorization`、`X-Mind-Purpose` 或 `X-Mind-Workspace-Id`。Center 不读取这些头,也不会回调 Mind 的授权接口。
|
||||||
|
- 成功响应只含 `conversationId`、语义化 `messages` 与 `page`;没有 `scope`、`workspaceId`、`mindUserId`、binding 或授权版本。Mind 以自己的调用上下文关联 workspace。
|
||||||
|
- `historyComplete=false` 时返回 `503 { error: { code: "history_incomplete" } }` 和 `Retry-After: 30`。其它分页、内容投影和错误码与 [Bright OneTalk 会话读取 API](./bright-conversation-list-api.md) 的消息读取契约一致。
|
||||||
|
|
||||||
|
## 部署与验收
|
||||||
|
|
||||||
|
Center 的 release workflow 会创建或验证该网络为 `internal`,并在启动后将容器 `trade-message-center` 接入其中。Mind 部署所有者需要在同一 Docker daemon 执行等价操作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network connect trade-message-center-summary <mind-container-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
发布后至少验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network inspect trade-message-center-summary
|
||||||
|
docker network inspect trade-message-center-public
|
||||||
|
docker port trade-message-center
|
||||||
|
docker exec <mind-container-name> \
|
||||||
|
curl -fsS 'http://trade-message-center:7777/api/bright/onetalk/accounts/<account>/conversations/<conversation>/messages?fromSentAtMs=<from>&toSentAtMs=<to>&limit=1'
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker port trade-message-center` 不得出现 `7777`。若 Center 回滚到不含内部 listener 的旧镜像,Mind 必须同时回滚其 base URL/请求头;共享网络不应在回滚时删除。
|
||||||
Reference in New Issue
Block a user