diff --git a/.env.example b/.env.example index 6d0270b..88b0812 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,6 @@ MIND_AUTH_BASE_URL=https://mind.example.com MIND_PAGE_ORIGIN=https://mind.example.com ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf MIND_AUTH_TIMEOUT_MS=3000 -# Mind 与 Center 共用的后台纪要历史只读凭据;缺失时后台读取返回 authorization_unavailable。 -TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN= # mind-test-harness 的独立联调页目标。该包只供顺道手工测试,不参与根 dev、质量门禁、build 或生产镜像。 MIND_TEST_HARNESS_HOST=127.0.0.1 diff --git a/.github/workflows/release_ci.yml b/.github/workflows/release_ci.yml index 9b792f1..2cf246d 100644 --- a/.github/workflows/release_ci.yml +++ b/.github/workflows/release_ci.yml @@ -51,7 +51,6 @@ jobs: MIND_PAGE_ORIGIN: http://127.0.0.1:7878 ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf MIND_AUTH_TIMEOUT_MS: "3000" - TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN: summary-read-token-for-ci-validation-only OSS_BUCKET: sinanpilot-bucket OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} @@ -104,9 +103,6 @@ jobs: pluginOrigins: [process.env.ONETALK_PLUGIN_ORIGINS], 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"); NODE @@ -241,7 +237,8 @@ jobs: MIND_PAGE_ORIGIN: ${{ vars.MIND_PAGE_ORIGIN }} ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf 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 }} SOURCE_TAG: ${{ github.ref_name }} @@ -292,7 +289,8 @@ jobs: : "${MIND_PAGE_ORIGIN:?MIND_PAGE_ORIGIN is required}" : "${ONETALK_PLUGIN_ORIGINS:?ONETALK_PLUGIN_ORIGINS 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_USER:?DEPLOY_USER is required}" : "${DEPLOY_PATH:?DEPLOY_PATH is required}" @@ -319,7 +317,6 @@ jobs: database_file="$build_state_path/database_url" oss_access_key_id_file="$build_state_path/oss_access_key_id" 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" target_key_file="$build_state_path/target_key" 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" ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \ "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" \ "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" \ "$build_env bash -s" <<'BUILD_SCRIPT' set -euo pipefail @@ -352,7 +347,7 @@ jobs: } 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 @@ -375,7 +370,6 @@ jobs: database_url="$(<"$DATABASE_FILE")" oss_access_key_id="$(<"$OSS_ACCESS_KEY_ID_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 HOST "$HOST" @@ -390,7 +384,6 @@ jobs: write_env_value MIND_PAGE_ORIGIN "$MIND_PAGE_ORIGIN" write_env_value ONETALK_PLUGIN_ORIGINS "$ONETALK_PLUGIN_ORIGINS" 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" 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")" \ < "$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" \ "$target_env bash -s" <<'TARGET_SCRIPT' set -euo pipefail image="$IMAGE_NAME:$IMAGE_TAG" env_file="$CURRENT_ENV_FILE" next_env_file="$NEXT_ENV_FILE" + public_network="$PUBLIC_NETWORK" + summary_network="$SUMMARY_NETWORK" cleanup() { rm -f "$next_env_file" } @@ -426,6 +421,49 @@ jobs: mkdir -p "$DEPLOY_PATH" 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)" restore_previous() { docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true @@ -433,20 +471,11 @@ jobs: return fi if [[ -s "$env_file" ]]; then - docker run -d \ - --name "$DEPLOY_CONTAINER_NAME" \ - --restart unless-stopped \ - --env-file "$env_file" \ - --publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \ - "$previous_image" + start_container "$previous_image" "$env_file" return fi echo "Restoring legacy image without an env-file: $previous_image" >&2 - docker run -d \ - --name "$DEPLOY_CONTAINER_NAME" \ - --restart unless-stopped \ - --publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \ - "$previous_image" + start_container "$previous_image" "" } migration_container="${DEPLOY_CONTAINER_NAME}-migration" @@ -457,12 +486,7 @@ jobs: "$image" \ node dist/src/database/migrate.js docker rm -f "$DEPLOY_CONTAINER_NAME" 2>/dev/null || true - if ! docker run -d \ - --name "$DEPLOY_CONTAINER_NAME" \ - --restart unless-stopped \ - --env-file "$next_env_file" \ - --publish "$DEPLOY_HOST_PORT:$CONTAINER_PORT" \ - "$image"; then + if ! start_container "$image" "$next_env_file"; then restore_previous exit 1 fi diff --git a/.trellis/spec/server/backend/directory-structure.md b/.trellis/spec/server/backend/directory-structure.md index a7e2ba2..5327eed 100644 --- a/.trellis/spec/server/backend/directory-structure.md +++ b/.trellis/spec/server/backend/directory-structure.md @@ -20,6 +20,7 @@ apps/ │ ├── app.ts │ ├── config.ts │ ├── entry.ts + │ ├── runtime.ts │ ├── database/ │ │ ├── index.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 不直接访问数据库。 - `websocket/registry.ts` 只管理已认证连接和 publish-after-commit 的 Mind 发布,不创建消息事实或 outbox。 - `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/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` 映射环境变量。 @@ -131,7 +133,7 @@ or (sent_at_ms = cursor.sentAtMs and message_id < cursor.messageId) - `limit` 默认 `50`,最大 `100`。repository 查询 `limit + 1` 行,服务层只返回前 `limit` 行并据此计算 `hasMore`。 - 首次请求取得最新的一页;API 响应的 `messages` 始终按旧到新排列。带 `nextCursor` 的下一页是更旧的一页,前端必须将其 prepend 到已有消息之前。 - 历史读取 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 @@ -139,7 +141,7 @@ or (sent_at_ms = cursor.sentAtMs and message_id < cursor.messageId) | --- | --- | | 普通读取未传两个时间戳 | 接受;无消息时间过滤,按最新页读取 | | `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 使用旧 v1 排序语义 | `400 invalid_cursor`;要求重新请求第一页 | | 数据库读取失败 | `503 database_unavailable` | diff --git a/.trellis/spec/server/backend/error-handling.md b/.trellis/spec/server/backend/error-handling.md index c06e182..8680a48 100644 --- a/.trellis/spec/server/backend/error-handling.md +++ b/.trellis/spec/server/backend/error-handling.md @@ -126,7 +126,7 @@ handleReadFailure(reply, error) -> database_unavailable | internal_error - 认证上下文缺失是 `401 auth_required`;scope 冲突是 `403 scope_mismatch`;授权适配器拒绝是 `403`,不可用是 `503`。 - 未知会话是 `404 conversation_not_found`;分页输入错误是 `400 invalid_limit` 或 `400 invalid_cursor`。 - `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"`;列表、详情和历史都不得通过客户端字段推断其它会话类型。 - 错误响应、WS error frame、页面可见状态和日志字段不得包含 credential、Cookie、连接串或完整原始 envelope。 @@ -142,8 +142,8 @@ handleReadFailure(reply, error) -> database_unavailable | internal_error | Authorization unavailable | `503 authorization_unavailable` | | Unknown conversation | `404 conversation_not_found` | | Invalid cursor or limit | `400 invalid_cursor` / `invalid_limit` | -| Invalid summary time window | `400 invalid_time_range` | -| Summary requested before history is complete | `503 history_incomplete` with `Retry-After: 30` | +| Internal summary time window invalid | `400 invalid_time_range` | +| Internal summary requested before history is complete | `503 history_incomplete` with `Retry-After: 30` | | Repository storage failure | `503 database_unavailable` | ### 5. Good / Base / Bad Cases diff --git a/.trellis/spec/server/backend/index.md b/.trellis/spec/server/backend/index.md index a0ebc54..51de3d9 100644 --- a/.trellis/spec/server/backend/index.md +++ b/.trellis/spec/server/backend/index.md @@ -20,7 +20,7 @@ | [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 | | [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 | | [服务基础设施](./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 | | [OneTalk 联系人资料 Bright 持久化](./mind-contact-profile.md) | profile composite key、严格时间前进 upsert、future-skew 拒绝、transaction/ACK fence 和 read-model 内存组合 | 已实现并有 focused tests;真实 PostgreSQL 另行验证 | diff --git a/.trellis/spec/server/backend/logging-guidelines.md b/.trellis/spec/server/backend/logging-guidelines.md index 733da21..9fc0126 100644 --- a/.trellis/spec/server/backend/logging-guidelines.md +++ b/.trellis/spec/server/backend/logging-guidelines.md @@ -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 these events as `[mind-auth][diagnostic]`. It must not print the upstream URL, Cookie, binding, 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,两者不能互相冒充验证。 diff --git a/.trellis/spec/server/backend/service-foundation.md b/.trellis/spec/server/backend/service-foundation.md index 6fa4b1b..7f15ffa 100644 --- a/.trellis/spec/server/backend/service-foundation.md +++ b/.trellis/spec/server/backend/service-foundation.md @@ -12,6 +12,7 @@ loadConfig(environment: Record): ServerConfig createDatabase(databaseUrl: string): { db: Database; close(): Promise } createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance +createServerRuntime(config: ServerConfig, dependencies?: ServerRuntimeDependencies): ServerRuntime startServer(): Promise ``` @@ -21,7 +22,7 @@ startServer(): Promise - 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" }`。 - 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。 - `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。 @@ -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? })`。 - 会话只读取 `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 例外。 -- 历史 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。 - `/harness` 只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,按 scope/account/conversation/messageId 去重。它按 `content.kind` 转义渲染文本、以真实 `` 加载图片预览并显示加载失败、为文件保留元数据且只在 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 @@ -249,9 +250,9 @@ GET /harness -> text/html (native browser page) | 授权依赖不可用 | HTTP `503`,`authorization_unavailable` | | limit 非整数或不在 `1..100` | HTTP `400`,`invalid_limit` | | 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` | -| history 尚未完整且请求 summary | HTTP `503 history_incomplete`,`Retry-After: 30` | +| history 尚未完整且请求 internal summary | HTTP `503 history_incomplete`,`Retry-After: 30` | | 数据库读取失败 | HTTP `503`,`database_unavailable`;不返回内部异常 | | 插件无在线连接 | 仍可读历史;响应/事件状态为 `offline`,实时发送能力保持禁用 | @@ -263,9 +264,9 @@ GET /harness -> text/html (native browser page) ### 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 对同一事实的公开投影等价,以及断线后的连接清理。 -- 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 后读取仍按账号/会话隔离。 ### 7. Wrong vs Correct diff --git a/.trellis/spec/server/backend/summary-authorization.md b/.trellis/spec/server/backend/summary-authorization.md index 04f8ae1..1b59656 100644 --- a/.trellis/spec/server/backend/summary-authorization.md +++ b/.trellis/spec/server/backend/summary-authorization.md @@ -1,77 +1,40 @@ -# 后台纪要专用只读授权 +# 后台纪要内部网络读取 ## 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 ```text -GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages -Authorization: Bearer -X-Mind-Purpose: communication_summary_read -X-Mind-Workspace-Id: +GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages Query: fromSentAtMs, toSentAtMs, limit?, cursor? - -POST /internal/bright/onetalk/authorize-summary -Authorization: Bearer -Body: { purpose: "communication_summary_read", workspaceId, channelAccountId, conversationId } -200: { purpose: "communication_summary_read", scope: { workspaceId, channelAccountId, conversationId }, permissions: ["read"] } +Response: { conversationId, messages, page } ``` ## 3. Contracts -- 专用 summary scope 的 owner 为 `onetalk-contract/src/summary-authorization.ts`,不含 `mindUserId`、binding 或 authorizationVersion,不扩展通用 OneTalk session/binding 授权语义。 -- 只有 messages 可选择 summary 授权。出现 Authorization 或后台 workspace header 后必须按后台分支处理,purpose 不符、凭据失败或回调失败不得落回 Cookie。纯 Cookie 请求保持既有规则,包括 Cookie + summary purpose。 -- 专用 Token 至少 32 字符,仅接受 ASCII token68 字符,`=` 仅允许尾部;配置与 Bearer 解析复用 `summary-credential.ts` 的规则,使用固定长度 digest 与常量时间比较。未配置时后台请求明确失败,不能制造占位 origin、猜测用户、选择默认 secret 或构造已授权 scope。 -- 回调仅访问配置的固定 Mind origin 和固定 path;限制超时、禁止 redirect,不转发 Cookie,不缓存跨页授权。每页再次授权。 -- 成功响应严格解码字段、purpose 和单一 read 权限,scope 三项必须逐项匹配请求。Mind 根据同 workspace 的精确 Alibaba 单聊、账号和 connection 路由事实核验,并拒绝缺失或歧义。 -- 复用既有 history read service/repository;分页保持页内升序、nextCursor 向更旧消息。每页固定相同 from/to,时间范围左闭右开,limit 最大 100,cursor 绑定账号/会话/时间范围与快照。Center 不承担 Mind 的跨页全局排序与首条历史定位。 -- 服务授权仅进入 history 输入,不能用于列表/详情、发送或 WS。cutover epoch 在异步授权和读取之后仍复核。 -- `TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` 通过 GitHub production secret 注入发布生成的 server.env;不能写入仓库、镜像或 SSH 命令参数。传输沿 stdin/受限临时文件链,结束清理,回滚应用与 env 成对执行。 -- 诊断只保留请求关联、阶段、稳定分类、耗时及结果统计;禁止 Token、Cookie、完整 URL、原始 scope、正文、原始异常和 DB 信息。 +- `entry.ts` 通过 runtime 组合 public 和 internal 两个 Fastify listener;internal listener 固定绑定 `0.0.0.0:7777`,只注册 history GET,不注册 WebSocket、CORS、列表、详情或扩展下载路由。 +- 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。 +- summary 必须有完整且有效的 `[fromSentAtMs,toSentAtMs)`;cursor 仍绑定账号、会话、窗口和 snapshot。`historyComplete=false` 返回 `503 history_incomplete` 和 `Retry-After: 30`。 +- public listener 只走 Cookie/Mind session reader;旧 summary headers 不创建未授权分支。`MIND_AUTH_BASE_URL` 继续只服务页面/plugin 的通用授权。 +- 发布仅 `--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` 不提供访问控制。 +- 网络成员可读取任何已知 account/conversation,Docker socket 持有者或宿主机 root 不受该边界约束。若需要 workspace 或会话级授权,必须另行恢复明确授权机制,不能从 Center 数据猜测。 ## 4. Validation & Error Matrix | 条件 | Center 行为 | | --- | --- | -| 错误/缺失 Bearer,带后台标记 | 401 auth_required,无 Cookie fallback | -| 后台配置缺失 | 503 authorization_unavailable | -| 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 | +| internal 请求缺少 from/to,或 from >= to | 400 invalid_time_range | +| cursor 跨账号/会话/窗口 | 400 invalid_cursor | | 历史未完整同步 | 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,最后以授权结果读取精确会话。 -- Base:本地 loopback HTTP callback fixture 验证请求/响应边界,但不代表真实 Mind PostgreSQL 归属核验或企业纪要恢复。 -- Bad:仅比较 Token 后把 workspace header 拼成已授权 scope,或给后台伪造用户/binding 来复用 WS reader。 -- Bad:缺配置返回空成功页,鉴权失败改走 Cookie,或者更改 Center 分页来掩盖 Mind 跨页排序错误。 - -## 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); -``` +- internal route 证明无认证读取、完整窗口、history gate、无 scope 响应、数据库/epoch 错误和 cursor 语义。 +- public HTTP/WS/CORS 回归证明 Cookie 授权保持,旧 summary headers 不改变路由选择。 +- release 静态检查证明 token、callback 和 secret 注入已删除;部署时用 `docker network inspect`、`docker port` 与 Mind 容器真实请求验证网络。 +- 真实 Docker 网络不可用时单列 external-unverified,不能以 HTTP injection 宣称已经完成双容器验收。 diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/check.jsonl b/.trellis/tasks/09-07-onetalk-summary-internal-network/check.jsonl new file mode 100644 index 0000000..bf8cf2a --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/check.jsonl @@ -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."} diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/design.md b/.trellis/tasks/09-07-onetalk-summary-internal-network/design.md new file mode 100644 index 0000000..c2e3c47 --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/design.md @@ -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. diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.jsonl b/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.jsonl new file mode 100644 index 0000000..c2ba8a5 --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.md b/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.md new file mode 100644 index 0000000..612769e --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/implement.md @@ -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. diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/prd.md b/.trellis/tasks/09-07-onetalk-summary-internal-network/prd.md new file mode 100644 index 0000000..29afe13 --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/prd.md @@ -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;该外部操作是端到端部署验收的前置条件,不是本仓库的代码变更。 diff --git a/.trellis/tasks/09-07-onetalk-summary-internal-network/task.json b/.trellis/tasks/09-07-onetalk-summary-internal-network/task.json new file mode 100644 index 0000000..0759d5e --- /dev/null +++ b/.trellis/tasks/09-07-onetalk-summary-internal-network/task.json @@ -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": {} +} \ No newline at end of file diff --git a/Dockerfile.server b/Dockerfile.server index 18114a5..ccef6a6 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -30,7 +30,7 @@ ENV NODE_ENV=production COPY --from=build /opt/server ./ -EXPOSE ${PORT} +EXPOSE ${PORT} 7777 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))" diff --git a/apps/mind-test-harness/src/harness.ts b/apps/mind-test-harness/src/harness.ts index a9a4c79..f9b5343 100644 --- a/apps/mind-test-harness/src/harness.ts +++ b/apps/mind-test-harness/src/harness.ts @@ -56,7 +56,6 @@ export const createOneTalkHarnessHtml = ( - @@ -71,7 +70,7 @@ export const createOneTalkHarnessHtml = ( -

HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor;纪要读取会发送 X-Mind-Purpose 并要求完整时间窗。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。

+

HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。

@@ -118,7 +117,6 @@ export const createOneTalkHarnessHtml = ( account: element('account-id'), conversationQuery: element('conversation-query'), conversation: element('conversation-id'), - readPurpose: element('read-purpose'), fromSentAtMs: element('from-sent-at-ms'), toSentAtMs: element('to-sent-at-ms'), cookie: element('mind-cookie'), @@ -230,8 +228,6 @@ export const createOneTalkHarnessHtml = ( channelAccountId: scope.channelAccountId } : null; }; - const readPurpose = () => fields.readPurpose.value === 'communication_summary_read' ? 'communication_summary_read' : 'normal'; - const apiUrlFor = (channelAccountId, conversationId) => { const base = conversationRoute.replace(':channelAccountId', encodeURIComponent(channelAccountId)); const path = base + (conversationId ? '/' + encodeURIComponent(conversationId) : ''); @@ -260,10 +256,6 @@ export const createOneTalkHarnessHtml = ( return url + '?' + query.toString(); }; - const requestHeadersForHistory = () => readPurpose() === 'communication_summary_read' - ? { 'X-Mind-Purpose': 'communication_summary_read' } - : {}; - const readResponse = async (url, requestHeaders = {}) => { const response = await fetch(url, { headers: requestHeaders, credentials: 'include' }); let body = null; @@ -585,7 +577,7 @@ export const createOneTalkHarnessHtml = ( if (state.historyRequestGeneration !== requestGeneration || state.conversationId !== conversationId) return; const url = historyUrlFor(); 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 (!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'); diff --git a/apps/onetalk-contract/src/index.ts b/apps/onetalk-contract/src/index.ts index 6ccd26e..ef9947e 100644 --- a/apps/onetalk-contract/src/index.ts +++ b/apps/onetalk-contract/src/index.ts @@ -4,4 +4,3 @@ export * from "./authorization.ts"; export * from "./content.ts"; export * from "./decoder.ts"; export * from "./model.ts"; -export * from "./summary-authorization.ts"; diff --git a/apps/onetalk-contract/src/summary-authorization.ts b/apps/onetalk-contract/src/summary-authorization.ts deleted file mode 100644 index 151d4a6..0000000 --- a/apps/onetalk-contract/src/summary-authorization.ts +++ /dev/null @@ -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 => - typeof value === "object" && value !== null && !Array.isArray(value); - -const hasExactKeys = (value: Record, 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"], - }, - }; -}; diff --git a/apps/onetalk-contract/test/summary-authorization.test.ts b/apps/onetalk-contract/test/summary-authorization.test.ts deleted file mode 100644 index 77727e3..0000000 --- a/apps/onetalk-contract/test/summary-authorization.test.ts +++ /dev/null @@ -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 }); -}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 5f6190a..5024e93 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -28,12 +28,6 @@ import type { OneTalkConnectionRegistry, OneTalkPublishFailureSink } from "./web import type { OneTalkProfileService, OneTalkReadService, OneTalkService } from "./onetalk/index.ts"; import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts"; import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts"; -import { - createSummaryAuthorizationReader, - createUnavailableSummaryAuthorizationReader, - type SummaryAuthorizationDiagnosticsSink, - type SummaryAuthorizationReader, -} from "./summary-authorization.ts"; export type AppDependencies = { database?: DatabaseConnection; @@ -45,26 +39,9 @@ export type AppDependencies = { onOneTalkPublishFailure?: OneTalkPublishFailureSink; onOneTalkDiagnostic?: OneTalkDiagnosticsSink; onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink; - onSummaryAuthorizationDiagnostic?: SummaryAuthorizationDiagnosticsSink; - summaryAuthorization?: SummaryAuthorizationReader; 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 = ( config: ServerConfig, explicitAuthorization: OneTalkAuthorizationReader | undefined, @@ -89,11 +66,6 @@ export const createApp = ( dependencies.authorization, dependencies.onMindAuthorizationDiagnostic, ); - const summaryAuthorization = summaryAuthorizationFor( - config, - dependencies.summaryAuthorization, - dependencies.onSummaryAuthorizationDiagnostic, - ); const cutoverPolicy = dependencies.cutoverPolicy ?? createOneTalkCutoverPolicy(); const mindPageOrigin = config.mindAuthorization?.mindPageOrigin ?? @@ -130,8 +102,6 @@ export const createApp = ( authorization, readService, registry: oneTalkRegistry, - summaryAuthorization, - onSummaryAuthorizationDiagnostic: dependencies.onSummaryAuthorizationDiagnostic, mindPageOrigin, cutoverPolicy, }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 82a971b..aa77b97 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,7 +4,6 @@ import { validateChromeExtensionDownloadConfig, type ChromeExtensionDownloadConfig, } from "./oss/chrome-extension-download.ts"; -import { isSummaryReadToken } from "./summary-credential.ts"; export type ServerConfig = { host: string; @@ -13,7 +12,6 @@ export type ServerConfig = { environment: ServerEnvironment; chromeExtensionDownload?: ChromeExtensionDownloadConfig; mindAuthorization?: MindAuthorizationConfig; - summaryReadAuthorization?: SummaryReadAuthorizationConfig; }; export type MindAuthorizationConfig = { @@ -23,10 +21,6 @@ export type MindAuthorizationConfig = { timeoutMs: number; }; -export type SummaryReadAuthorizationConfig = { - token: string; -}; - export const readServerEnvironment = (): NodeJS.ProcessEnv => process.env; export type ServerEnvironment = "development" | "non_development"; @@ -119,17 +113,6 @@ const parseMindAuthorization = ( }; }; -const parseSummaryReadAuthorization = ( - environment: Record, -): 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 = ( environment: Record, ): ChromeExtensionDownloadConfig | undefined => { @@ -162,6 +145,5 @@ export const loadConfig = (environment: Record): Ser environment, normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test", ), - summaryReadAuthorization: parseSummaryReadAuthorization(environment), }; }; diff --git a/apps/server/src/entry.ts b/apps/server/src/entry.ts index 004f3c3..517bbce 100644 --- a/apps/server/src/entry.ts +++ b/apps/server/src/entry.ts @@ -2,11 +2,10 @@ import { pathToFileURL } from "node:url"; -import { createApp } from "./app.ts"; import { loadConfig, readServerEnvironment } from "./config.ts"; import type { MindAuthorizationDiagnosticsSink } from "./mind-authorization.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 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 ( onOneTalkDiagnostic?: OneTalkDiagnosticsSink, onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink, - onSummaryAuthorizationDiagnostic: SummaryAuthorizationDiagnosticsSink = (event) => { - console.error(JSON.stringify(event)); - }, ): Promise => { const config = loadConfig(readServerEnvironment()); - const app = createApp(config, { + const runtime = createServerRuntime(config, { onOneTalkDiagnostic, onMindAuthorizationDiagnostic, - onSummaryAuthorizationDiagnostic, }); const shutdown = async (): Promise => { - await app.close(); + await runtime.close(); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); try { - await app.listen({ host: config.host, port: config.port }); + await runtime.listen(); } catch (error: unknown) { - await app.close(); throw error; } }; diff --git a/apps/server/src/http/onetalk-summary.ts b/apps/server/src/http/onetalk-summary.ts new file mode 100644 index 0000000..03342fc --- /dev/null +++ b/apps/server/src/http/onetalk-summary.ts @@ -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(); + 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 => { + 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, + }); + }, + ); +}; diff --git a/apps/server/src/http/onetalk.ts b/apps/server/src/http/onetalk.ts index 1fe9265..9f464e5 100644 --- a/apps/server/src/http/onetalk.ts +++ b/apps/server/src/http/onetalk.ts @@ -6,34 +6,27 @@ import { ONETALK_CONVERSATION_ROUTE, ONETALK_HISTORY_ROUTE, ONETALK_PROTOCOL_VERSION, - ONETALK_SUMMARY_READ_PURPOSE, } from "@trade-message-center/onetalk-contract"; import type { OneTalkAuthorizationDecision, OneTalkAuthorizationReader, OneTalkMindScope, - OneTalkSummaryReadScope, } from "@trade-message-center/onetalk-contract"; import { OneTalkDatabaseError, type CenterConversation, type CenterMessage, + type OneTalkHistoryReadPurpose, type OneTalkReadService, } from "../onetalk/index.ts"; import type { OneTalkConnectionRegistry } from "../websocket/registry.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 MINIMUM_PAGE_LIMIT = 1; 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"; type AccountParams = { @@ -99,7 +92,7 @@ export type BrightConversationResponse = { }; export type BrightHistoryResponse = { - scope: OneTalkMindScope | OneTalkSummaryReadScope; + scope: OneTalkMindScope; conversationId: string; messages: CenterMessage[]; page: { @@ -114,8 +107,6 @@ export type BrightReadRouteOptions = { registry?: OneTalkConnectionRegistry; mindPageOrigin?: string; cutoverPolicy: OneTalkCutoverPolicy; - summaryAuthorization: SummaryAuthorizationReader; - onSummaryAuthorizationDiagnostic?: SummaryAuthorizationDiagnosticsSink; }; type AuthorizationResult = @@ -136,10 +127,6 @@ type AuthorizedReadScopeResult = | { ok: true; scope: OneTalkMindScope } | { ok: false; response: FastifyReply }; -type AuthorizedSummaryReadScopeResult = - | { ok: true; scope: OneTalkSummaryReadScope } - | { ok: false; response: FastifyReply }; - const authorizationCodeFor = (decision: OneTalkAuthorizationDecision): AuthorizationResult => { if (decision.allowed) return { ok: true, scope: decision.mindScope }; const statusCode = @@ -182,7 +169,7 @@ const authorizeRead = async ( return { ok: true, scope: decision.mindScope }; }; -const sendError = ( +export const sendError = ( reply: FastifyReply, statusCode: number, code: BrightReadErrorCode, @@ -204,61 +191,6 @@ const authorizeRequestScope = async ( 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 => { - 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>; - 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 => { if (origin === undefined || request.headers.origin !== origin) return; reply.header("access-control-allow-origin", origin); @@ -283,7 +215,7 @@ const pluginStatusFor = ( scope: OneTalkMindScope, ): 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 (typeof value !== "string" || !/^\d+$/.test(value)) return null; const limit = Number(value); @@ -292,14 +224,14 @@ const parseLimit = (value: unknown): number | undefined | null => { : null; }; -const parseOptionalSafeInteger = (value: unknown): number | undefined | null => { +export const parseOptionalSafeInteger = (value: unknown): number | undefined | null => { if (value === undefined) return undefined; if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null; const parsed = Number(value); 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; return typeof value === "string" ? value : null; }; @@ -309,10 +241,6 @@ const parseQuery = (value: unknown): string | undefined | 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 => { if (value === undefined) return true; if (Array.isArray(value)) return false; @@ -330,29 +258,77 @@ const allowsCorsRequestMethod = (value: string | string[] | undefined): boolean 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) { return sendError(reply, 503, "database_unavailable"); } return null; }; -const reportSummaryRead = ( - options: BrightReadRouteOptions, - request: FastifyRequest, - startedAt: number | null, - outcome: "succeeded" | "failed" | "waiting", - code?: BrightReadErrorCode, -): void => { - if (startedAt === null) return; - reportSummaryAuthorizationDiagnostic(options.onSummaryAuthorizationDiagnostic, { - event: "onetalk_summary_authorization", - requestId: request.id, - stage: "read", - outcome, - ...(code === undefined ? {} : { code }), - durationMs: Date.now() - startedAt, - }); +type HistoryReadSuccess = { + conversationId: string; + messages: CenterMessage[]; + page: { hasMore: boolean; nextCursor: string | null }; +}; + +export type HistoryReadReplyOptions = { + readService: OneTalkReadService; + scope: Pick; + conversationId: string; + query: BrightHistoryQuery; + purpose: OneTalkHistoryReadPurpose; + isAdmitted: () => boolean; + toResponse: (result: HistoryReadSuccess) => unknown; +}; + +/** 执行历史读取并映射共享的参数、分页与稳定错误语义。 */ +export const replyWithHistoryRead = async ( + reply: FastifyReply, + options: HistoryReadReplyOptions, +): Promise => { + 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 的消息读取路由。 */ @@ -374,7 +350,6 @@ export const installOneTalkReadRoutes = ( return void sendError(reply, 503, "authorization_unavailable"); } requestEpochs.set(request, options.cutoverPolicy.capture()); - if (hasSummaryReadAttempt(request)) return; if (rejectUnexpectedOrigin(request, reply, options.mindPageOrigin)) return; applyCors(reply, request, options.mindPageOrigin); }; @@ -394,15 +369,13 @@ export const installOneTalkReadRoutes = ( reply.header("access-control-allow-credentials", "true"); reply.header("vary", "Origin"); 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(); }); app.get<{ Params: AccountParams; Querystring: BrightConversationListQuery }>( ONETALK_CONVERSATIONS_ROUTE, { preHandler: mindOriginGuard }, async (request, reply) => { - if (hasSummaryReadAttempt(request)) - return sendError(reply, 403, "authorization_rejected"); const authorizedScope = await authorizeRequestScope( request, reply, @@ -448,8 +421,6 @@ export const installOneTalkReadRoutes = ( ONETALK_CONVERSATION_ROUTE, { preHandler: mindOriginGuard }, async (request, reply) => { - if (hasSummaryReadAttempt(request)) - return sendError(reply, 403, "authorization_rejected"); const authorizedScope = await authorizeRequestScope( request, reply, @@ -487,172 +458,30 @@ export const installOneTalkReadRoutes = ( ONETALK_HISTORY_ROUTE, { preHandler: mindOriginGuard }, async (request, reply) => { - const summaryAttempt = hasSummaryReadAttempt(request); - if (summaryAttempt) reply.header("cache-control", "no-store"); - const authorizedScope = summaryAttempt - ? await authorizeSummaryRequestScope( - request, - reply, - options.summaryAuthorization, - request.params.channelAccountId, - request.params.conversationId, - ) - : await authorizeRequestScope( - request, - reply, - options.authorization, - request.params.channelAccountId, - ); + const authorizedScope = await authorizeRequestScope( + request, + reply, + options.authorization, + request.params.channelAccountId, + ); if (!authorizedScope.ok) return authorizedScope.response; if (!requestIsAdmitted(request)) return sendError(reply, 503, "authorization_unavailable"); - - const summaryReadStartedAt = summaryAttempt ? Date.now() : null; - - const fromSentAtMs = parseOptionalSafeInteger(request.query.fromSentAtMs); - const toSentAtMs = parseOptionalSafeInteger(request.query.toSentAtMs); - const limit = parseLimit(request.query.limit); - const cursor = parseOpaqueCursor(request.query.cursor); - const purpose = summaryAttempt - ? SUMMARY_PURPOSE - : summaryPurposeFor(request.headers["x-mind-purpose"]); - if (fromSentAtMs === null || toSentAtMs === null) { - reportSummaryRead( - options, - request, - 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"); - } + return replyWithHistoryRead(reply, { + readService: options.readService, + scope: authorizedScope.scope, + conversationId: request.params.conversationId, + query: request.query, + purpose: "normal", + isAdmitted: () => requestIsAdmitted(request), + toResponse: (result) => + ({ + scope: authorizedScope.scope, + conversationId: result.conversationId, + messages: result.messages, + page: result.page, + }) satisfies BrightHistoryResponse, + }); }, ); }; diff --git a/apps/server/src/onetalk/index.ts b/apps/server/src/onetalk/index.ts index f0b0fc8..e1e0f02 100644 --- a/apps/server/src/onetalk/index.ts +++ b/apps/server/src/onetalk/index.ts @@ -48,6 +48,7 @@ export type { OneTalkConversationReadResult, OneTalkHistoryReadCursor, OneTalkHistoryReadInput, + OneTalkHistoryReadPurpose, OneTalkHistoryReadResult, OneTalkHistoryWindow, OneTalkListCursor, @@ -60,3 +61,4 @@ export type { OneTalkReadService, OneTalkReadServiceDependencies, } from "./read-model.ts"; +export { ONETALK_SUMMARY_READ_PURPOSE } from "./read-model.ts"; diff --git a/apps/server/src/onetalk/read-model.ts b/apps/server/src/onetalk/read-model.ts index 93e7e99..201fb6b 100644 --- a/apps/server/src/onetalk/read-model.ts +++ b/apps/server/src/onetalk/read-model.ts @@ -9,6 +9,8 @@ import type { export const ONETALK_READ_DEFAULT_LIMIT = 50; 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 = { channelAccountId: string; @@ -139,7 +141,7 @@ export type OneTalkHistoryReadInput = { toSentAtMs?: number | null; cursor?: string | null; limit?: number; - purpose?: "normal" | "communication_summary_read"; + purpose?: OneTalkHistoryReadPurpose; }; /** 每页消息按旧到新返回;nextCursor 继续读取当前页之前的更旧消息。 */ diff --git a/apps/server/src/onetalk/read-service.ts b/apps/server/src/onetalk/read-service.ts index c84f2e2..d986472 100644 --- a/apps/server/src/onetalk/read-service.ts +++ b/apps/server/src/onetalk/read-service.ts @@ -9,6 +9,7 @@ import { import { ONETALK_READ_DEFAULT_LIMIT, ONETALK_READ_MAX_LIMIT, + ONETALK_SUMMARY_READ_PURPOSE, type OneTalkConversationListInput, type OneTalkConversationListResult, type OneTalkConversationReadInput, @@ -181,7 +182,7 @@ export const createOneTalkReadService = ( asOf, }); 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" }; } diff --git a/apps/server/src/runtime.ts b/apps/server/src/runtime.ts new file mode 100644 index 0000000..28da26e --- /dev/null +++ b/apps/server/src/runtime.ts @@ -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; + close: () => Promise; +}; + +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 => { + await internalSummaryApp.close(); + await publicApp.close(); + }; + const listen = async (): Promise => { + 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 }; +}; diff --git a/apps/server/src/summary-authorization.ts b/apps/server/src/summary-authorization.ts deleted file mode 100644 index b756a47..0000000 --- a/apps/server/src/summary-authorization.ts +++ /dev/null @@ -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; - -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; -}; - -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; - 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; - }, - }; -}; diff --git a/apps/server/src/summary-credential.ts b/apps/server/src/summary-credential.ts deleted file mode 100644 index 5d77461..0000000 --- a/apps/server/src/summary-credential.ts +++ /dev/null @@ -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; -}; diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index d1be659..8c0e4d4 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -6,6 +6,8 @@ import test from "node:test"; import { createApp } from "../src/app.ts"; import { createDatabase, type DatabaseConnection } from "../src/database/index.ts"; import { loadConfig } from "../src/config.ts"; +import { createServerRuntime } from "../src/runtime.ts"; +import type { OneTalkReadService } from "../src/onetalk/index.ts"; const testConfig = { 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", () => { const base = { HOST: "127.0.0.1", @@ -62,40 +104,6 @@ test("requires strict production Mind authorization configuration", () => { ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id", }; 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.throws( () => loadConfig({ ...base, TMC_PACKAGE_VERSION: "1.2" }), diff --git a/apps/server/test/onetalk-http.test.ts b/apps/server/test/onetalk-http.test.ts index 9e513b8..e4878c2 100644 --- a/apps/server/test/onetalk-http.test.ts +++ b/apps/server/test/onetalk-http.test.ts @@ -1,9 +1,8 @@ // 验证 Bright direct 会话读取 HTTP 边界与原生联调页 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 Fastify from "fastify"; import { createMockAuthorizationReader, @@ -13,6 +12,7 @@ import { import { createApp } from "../src/app.ts"; import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts"; import type { DatabaseConnection } from "../src/database/index.ts"; +import { installInternalSummaryRoute } from "../src/http/onetalk-summary.ts"; import { OneTalkDatabaseError, type CenterConversation, @@ -20,9 +20,6 @@ import { type OneTalkReadService, } 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 = { host: "127.0.0.1", port: 3000, @@ -126,6 +123,15 @@ const closeApp = async (app: ReturnType): Promise => { 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 () => { const app = createApp(testConfig, { 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[0] | undefined; const app = createApp(testConfig, { database: createDatabaseStub(), @@ -281,7 +287,7 @@ test("forwards the half-open window, summary purpose, and opaque history cursor url: historyUrl() + "?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.deepEqual(received, { @@ -291,7 +297,7 @@ test("forwards the half-open window, summary purpose, and opaque history cursor toSentAtMs: 20, limit: 1, cursor: "opaque-history-cursor", - purpose: "communication_summary_read", + purpose: "normal", }); const body = response.json(); 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 () => { - let receivedHeaders: IncomingMessage["headers"] | undefined; - let receivedBody: unknown; - const diagnostics: unknown[] = []; - const callback = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - receivedHeaders = request.headers; - receivedBody = JSON.parse(Buffer.concat(chunks).toString("utf8")); - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - purpose: "communication_summary_read", - scope: { - workspaceId: summaryWorkspaceId, - channelAccountId: pluginScope.channelAccountId, - conversationId: conversation.conversationId, - }, - permissions: ["read"], - }), - ); - }); - }); - await new Promise((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}`, +test("serves internal summary history without credentials or a returned authorization scope", async () => { + let received: Parameters[0] | undefined; + const app = createInternalSummaryApp( + createReadService({ + readHistory: async (input) => { + received = input; + return { + status: "accepted", + conversationId: conversation.conversationId, + messages: [message("message-1", 10)], + page: { hasMore: false, nextCursor: null }, + }; }, - 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((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 { const response = await app.inject({ method: "GET", url: historyUrl() + "?fromSentAtMs=10&toSentAtMs=20", headers: { - ...headers(), - authorization: `Bearer ${summaryToken}`, + authorization: "Bearer stale-summary-token", + cookie: "mind_session=unused", "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.deepEqual(response.json(), { error: { code: "authorization_unavailable" } }); - assert.equal(response.body.includes("dependency secret"), false); - assert.equal(pageAuthorizationCalls, 0); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["cache-control"], "no-store"); + assert.deepEqual(received, { + 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 { 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; - const app = createApp(testConfig, { - database: createDatabaseStub(), - authorization: createMockAuthorizationReader([authorizationRecord]), - readService: createReadService({ + const app = createInternalSummaryApp( + createReadService({ readHistory: async () => { calls += 1; return { @@ -480,25 +412,77 @@ test("rejects summary reads unless both window endpoints are present", async () }; }, }), - }); + ); try { for (const suffix of ["", "?fromSentAtMs=10", "?toSentAtMs=20"]) { - const response = await app.inject({ - method: "GET", - url: historyUrl() + suffix, - headers: { ...headers(), "x-mind-purpose": "communication_summary_read" }, - }); + const response = await app.inject({ method: "GET", url: historyUrl() + suffix }); assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: { code: "invalid_time_range" } }); } assert.equal(calls, 0); } 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; const app = createApp(testConfig, { database: createDatabaseStub(), @@ -507,9 +491,6 @@ test("keeps message input failures, not found, and summary history gates distinc readHistory: async (input) => { calls += 1; 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" }; }, }), @@ -540,15 +521,6 @@ test("keeps message input failures, not found, and summary history gates distinc }); assert.equal(notFound.statusCode, 404); 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 { 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; const authorization = createMockAuthorizationReader([authorizationRecord]); 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.headers["access-control-allow-origin"], "http://mind.localhost"); 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"); const allowedHeaders = await app.inject({ @@ -609,7 +581,7 @@ test("preflight allows the summary header and a rejected origin cannot invoke au headers: { origin: "http://mind.localhost", "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); @@ -629,7 +601,7 @@ test("preflight allows the summary header and a rejected origin cannot invoke au headers: { origin: "http://mind.localhost", "access-control-request-method": "GET", - "access-control-request-headers": "x-unapproved-header", + "access-control-request-headers": "x-mind-purpose", }, }); assert.equal(disallowedHeader.statusCode, 403); diff --git a/apps/server/test/summary-authorization.test.ts b/apps/server/test/summary-authorization.test.ts deleted file mode 100644 index 7cafb01..0000000 --- a/apps/server/test/summary-authorization.test.ts +++ /dev/null @@ -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).cookie, undefined); - assert.equal((init?.headers as Record).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("not-json"); - return; - } - response.writeHead(500, { "content-type": "application/json" }); - response.end(JSON.stringify({ code: "authorization_unavailable" })); - }); - await new Promise((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((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, - }); - } -}); diff --git a/apps/server/test/websocket.test.ts b/apps/server/test/websocket.test.ts index abaac9a..eb32909 100644 --- a/apps/server/test/websocket.test.ts +++ b/apps/server/test/websocket.test.ts @@ -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 () => { - const summaryToken = "summary-read-token-with-at-least-thirty-two-characters"; +test("does not treat an arbitrary Bearer header as a Mind WebSocket identity", async () => { + const bearerToken = "retired-internal-read-token"; const authorization = createMindAuthorizationReader({ baseUrl: "https://mind.example.com", 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", { headers: { origin: "http://mind.localhost", - authorization: `Bearer ${summaryToken}`, - "x-mind-workspace-id": "workspace-1", + authorization: `Bearer ${bearerToken}`, }, }); @@ -478,7 +477,7 @@ test("does not treat a summary Bearer header as a Mind WebSocket identity", asyn protocolVersion: ONETALK_PROTOCOL_VERSION, connectionType: "mind_page", type: "ws.hello", - requestId: "summary-token-hello", + requestId: "bearer-token-hello", scope: mindScope, 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, connectionType: "mind_page", type: "ws.error", - requestId: "summary-token-hello", + requestId: "bearer-token-hello", scope: mindScope, payload: { code: ONETALK_ERROR_CODES.authRequired }, }); diff --git a/docs/bright-conversation-list-api.md b/docs/bright-conversation-list-api.md index 1864626..4c08903 100644 --- a/docs/bright-conversation-list-api.md +++ b/docs/bright-conversation-list-api.md @@ -14,7 +14,7 @@ Access-Control-Allow-Credentials: true 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 type Scope = { @@ -172,28 +172,9 @@ type CenterMessage = 响应绝不暴露原始数字或原始顶层字段,包括原始 contentType、readStatus、messageStatus、unreadCount、messageRevision、conversationRevision、updatedAtMs、recalledAtMs、deliveryStatus、顶层 text、顶层 subject 和 attachmentSummaryText。 -## 纪要读取门槛 +## 内部纪要读取 -普通页面读取可省略时间窗,并可读取已持久化的部分历史。Mind 读取纪要窗口时必须同时提供两个时间端点,并发送精确 header: - -```http -X-Mind-Purpose: communication_summary_read -``` - -若该会话 historyComplete 为 false,Bright 返回: - -```http -503 Service Unavailable -Retry-After: 30 -``` - -```ts -{ - error: { - code: "history_incomplete"; - } -} -``` +本页面 API 只接受 Mind Session/Cookie 授权;`Authorization`、`X-Mind-Purpose` 和 `X-Mind-Workspace-Id` 不会选择另一条读取路径。供 Mind 后台生成纪要的内部 Docker 网络接口、固定时间窗和 `history_incomplete` 规则见 [OneTalk 内部纪要读取接口](./onetalk-summary-internal-api.md)。 ## 分页与错误 diff --git a/docs/onetalk-summary-internal-api.md b/docs/onetalk-summary-internal-api.md new file mode 100644 index 0000000..351b2e5 --- /dev/null +++ b/docs/onetalk-summary-internal-api.md @@ -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 +``` + +发布后至少验证: + +```bash +docker network inspect trade-message-center-summary +docker network inspect trade-message-center-public +docker port trade-message-center +docker exec \ + curl -fsS 'http://trade-message-center:7777/api/bright/onetalk/accounts//conversations//messages?fromSentAtMs=&toSentAtMs=&limit=1' +``` + +`docker port trade-message-center` 不得出现 `7777`。若 Center 回滚到不含内部 listener 的旧镜像,Mind 必须同时回滚其 base URL/请求头;共享网络不应在回滚时删除。