refactor(server): move OneTalk summaries to internal network

This commit is contained in:
YBF
2026-09-07 19:12:03 +08:00
parent 6e8eca944d
commit 161e166b2c
36 changed files with 772 additions and 1261 deletions
-2
View File
@@ -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
+55 -31
View File
@@ -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
@@ -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` |
@@ -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
+1 -1
View File
@@ -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 另行验证 |
@@ -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,两者不能互相冒充验证。
@@ -12,6 +12,7 @@
loadConfig(environment: Record<string, string | undefined>): ServerConfig
createDatabase(databaseUrl: string): { db: Database; close(): Promise<void> }
createApp(config: ServerConfig, dependencies?: AppDependencies): FastifyInstance
createServerRuntime(config: ServerConfig, dependencies?: ServerRuntimeDependencies): ServerRuntime
startServer(): Promise<void>
```
@@ -21,7 +22,7 @@ startServer(): Promise<void>
- Optional `NODE_ENV` is normalized at the configuration boundary; only exact trimmed `development` enables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。
- `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` 转义渲染文本、以真实 `<img>` 加载图片预览并显示加载失败、为文件保留元数据且只在 URL 存在时提供带 `noopener noreferrer` 的用户触发链接;不解 raw 字段、不自动下载、不增加媒体发送。
- 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。HTTP CORS 只允许精确 Origin 和 `Content-Type`/`X-Mind-Purpose`
- 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`internal summary listener 不注册 CORS
### 4. Validation & Error Matrix
@@ -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。
- HTTPpublic 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;text/image/file 必须只含 normalized content。
- WebSocketMind 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
@@ -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 <TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN>
X-Mind-Purpose: communication_summary_read
X-Mind-Workspace-Id: <workspaceId>
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages
Query: fromSentAtMs, toSentAtMs, limit?, cursor?
POST <MIND_AUTH_BASE_URL>/internal/bright/onetalk/authorize-summary
Authorization: Bearer <same configured secret>
Body: { purpose: "communication_summary_read", workspaceId, channelAccountId, conversationId }
200: { purpose: "communication_summary_read", scope: { workspaceId, channelAccountId, conversationId }, permissions: ["read"] }
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 listenerinternal 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/conversationDocker 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_incompleteRetry-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 宣称已经完成双容器验收
@@ -0,0 +1,5 @@
{"file":".trellis/spec/project/architecture.md","reason":"Review application/runtime ownership after introducing the internal listener."}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Review required focused tests, static checks, and production-like verification boundary."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Review public/private error isolation and absence of auth fallbacks."}
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/prd.md","reason":"Check the implementation against accepted scope, exposure, and documentation criteria."}
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/design.md","reason":"Check listener, Docker-network, rollback, and ownership invariants."}
@@ -0,0 +1,75 @@
# Center 摘要内部网络入口设计
## 设计结论
摘要历史读取不再是 public history route 的认证分支。一个 Node 进程内运行两个独立 Fastify listenerpublic app 继续在 `7878` 服务全部既有能力;internal summary app 固定在 `0.0.0.0:7777`,只注册 history GET。Docker 的 `trade-message-center-summary` internal network 及成员资格成为 summary 的唯一权限边界。
```text
Internet / extension / Mind page
|
host publish :7878
|
Center public Fastify app
Cookie + Mind page authorization, WebSocket, normal HTTP
Mind container -- trade-message-center-summary (internal Docker network)
|
http://trade-message-center:7777
|
Center internal summary Fastify app
history GET only; no Cookie/Bearer/header authorization or Mind callback
|
shared OneTalk read service -> Bright PostgreSQL
```
The two containers must run on the same Docker daemon. The network prevents Docker port publication for `7777`; it is not encryption, mTLS, or a defence against another member container, a Docker-socket holder, or host root.
## Listener and runtime ownership
- Keep the existing `createApp` as the public application factory. Its read routes retain the complete Cookie/Mind reader and must no longer branch to summary authorization when `Authorization`, `X-Mind-Purpose`, or `X-Mind-Workspace-Id` appears.
- Add a dedicated internal app factory that registers only the existing history route constant and a handler which invokes the canonical `OneTalkReadService.readHistory`. It registers neither WebSocket, CORS/origin guards, extension-download routes, list/detail routes, nor generic Mind authorization.
- Add a runtime composition boundary that creates one `DatabaseConnection`, one read repository/service, and one cutover policy, injects the shared read service into both apps, starts public `config.host:config.port` and internal `0.0.0.0:7777`, and owns ordered shutdown. Close the internal app before the public app so the public app's existing database-close hook remains the one close owner.
- Failure to bind either listener closes both apps and fails process startup. No port fallback, disabled internal route, or default-to-public behavior is allowed.
- Preserve the current read-service facts: only `channelAccountId` is needed for a history query, opaque cursors remain bound to account/conversation/window/asOf, summary mode still requires both time boundaries and keeps `history_incomplete` plus `Retry-After: 30`.
## Internal HTTP contract
Mind calls the same history path on the private port to minimize path migration:
```http
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=:inclusiveEpochMs&toSentAtMs=:exclusiveEpochMs&cursor=:opaqueCursor&limit=:1to100
```
- The internal listener itself selects summary mode. It ignores no authorization claim because it reads none: `Authorization`, Cookie, `X-Mind-Purpose`, and `X-Mind-Workspace-Id` are not request-contract fields and the Mind document tells callers to omit them.
- A successful internal response contains `conversationId`, semantic `messages`, and `page`; it has no `scope`, `workspaceId`, `mindUserId`, binding, authorization version, or authorization diagnostics. `channelAccountId` and `conversationId` are the request path identity, not a returned authorization claim.
- Input/database/history errors keep the existing stable response shape. `auth_required`, `scope_mismatch`, `authorization_rejected`, and `authorization_unavailable` are not internal-summary outcomes.
- The public route ignores the former summary headers and only executes the existing Cookie/Mind page authorization path. Therefore an unauthenticated public request cannot reach a summary read through `7878`.
`ONETALK_SUMMARY_READ_PURPOSE` remains only as a server-domain signal between the internal HTTP handler and `OneTalkReadService`; move it out of the shared summary-authorization contract so it cannot be interpreted as an external header contract.
## Removed and preserved boundaries
Remove the summary-specific shared contract and export, credential predicate, summary reader/callback client, `TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` config/env/release secret flow, workspace/purpose headers, callback diagnostics, response-scope union, and their tests. Update the prior summary-authorization server spec instead of leaving it as active guidance.
Do not remove `MIND_AUTH_BASE_URL`, its generic reader, Cookie authorization, WebSocket authorization, CORS for the public app, public `7878`, or any history/repository/cursor/projection behavior. No migration or Mind database access is introduced.
## Docker release contract
- The deploy workflow uses the fixed named network `trade-message-center-summary`. Before each start or restore, it verifies that an existing network is `Internal=true`, or creates it with `docker network create --internal`.
- Center starts on the named non-internal `trade-message-center-public` bridge network for its published `7878` endpoint and outbound generic Mind authorization calls. It must not remain on Docker's default bridge, where other default-bridge members could reach every listening port by container IP. After `docker run --network trade-message-center-public --publish 7878:7878`, the workflow attaches it to `trade-message-center-summary`; it must not publish `7777`.
- Docker's automatic alias for the existing container name `trade-message-center` is the internal DNS target. The Mind owner attaches its container to the same named network and calls the URL above.
- The Dockerfile documents both container ports with `EXPOSE 7878 7777`, but `EXPOSE` is not an exposure control. The existing health check stays on `127.0.0.1:$PORT/health` and covers the public app; internal request smoke verification is a deployment check.
- Network creation/attachment failure fails release and follows the existing image/env rollback path. The network is not removed on rollback because it is shared with Mind. Rolling back Center after Mind changes back to `7777` requires coordinated Mind rollback or its traffic will fail; document this explicitly.
## Documentation and external handoff
Create `docs/onetalk-summary-internal-api.md` as the handoff document for Mind. It will contain the exact URL/path/query, request/response/error rules, network name, `docker network connect` example, no-port-publication rule, verification commands, shared-daemon precondition, and the deliberate loss of workspace/conversation authorization. Update `docs/bright-conversation-list-api.md` so its public summary-header section no longer contradicts the internal contract and links to the new document.
Mind code/deployment changes are not part of this task. A live Mind-to-Center container request is external validation, not evidence obtainable from Center unit tests.
## Test and rollback boundaries
- Unit and HTTP-injection tests prove the private handler is summary mode without credentials, validates windows, returns no scope, and preserves cursor/history gates. Public regression tests prove Bearer/workspace/purpose inputs do not select an unauthenticated route and that Cookie/WS behavior is unchanged.
- Runtime tests use ephemeral ports through the new runtime composition seam to prove both listener lifecycles and bind failure cleanup without depending on host port `7777`.
- Static checks prove summary credentials/callback modules and active config/release references are gone; archived task evidence is excluded from this deletion check.
- Docker daemon access is currently unavailable locally, so `docker network inspect`, `docker port`, and container-to-container HTTP are explicitly external deployment verification.
@@ -0,0 +1,5 @@
{"file":".trellis/spec/project/architecture.md","reason":"Defines shared app/module boundaries for adding a second listener without creating a second business owner."}
{"file":".trellis/spec/project/missing-values.md","reason":"Required for the fixed internal port and removal of the summary credential configuration."}
{"file":".trellis/spec/server/backend/service-foundation.md","reason":"Defines Fastify lifecycle, application composition, and server runtime conventions."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Defines stable HTTP error and security-boundary behavior for the internal handler."}
{"file":".trellis/tasks/09-07-onetalk-summary-internal-network/design.md","reason":"Approved task architecture, rollout boundary, and exact private listener contract."}
@@ -0,0 +1,32 @@
# Center 摘要内部网络入口实施计划
## Preconditions
- Preserve the current dirty working tree. The existing summary-authorization diff is task-relevant input; make targeted replacements only and do not reset, checkout, or discard unrelated user work.
- Before changing any symbol, run GitNexus upstream impact for `loadConfig`, `createApp`, `startServer`, and `installOneTalkReadRoutes`. Report any HIGH/CRITICAL result before editing.
- Before code edits, load `trellis-before-dev` and the project/server specs it routes to; it must cover config/default handling, HTTP errors, module ownership, service foundation, and quality checks.
## Implementation order
1. Replace the shared summary-authorization contract with a server-local summary-read purpose owned by the read model/service boundary. Remove its package export and all token/callback/credential modules and dependencies.
2. Simplify `loadConfig` and environment examples: remove the summary token configuration and validation, retain generic Mind authorization, and define the fixed internal listener contract without creating an environment-configurable fallback port.
3. Extract a runtime composition seam that owns one database/read-service/cutover-policy instance and can start/close both Fastify apps in deterministic order. Keep `createApp` compatible with existing direct tests and its existing database close behavior.
4. Make the public read-route installer use only the Cookie/Mind page authorization path; remove summary header/Bearer selection, summary callback diagnostics, and summary scope response handling.
5. Add the internal summary-only app and history route. It calls the same read service with server-selected summary purpose, accepts only the documented path/query contract, returns no authorization scope, and never installs public-only routes or CORS/origin authorization.
6. Change the entrypoint to bind public `HOST:PORT` and private `0.0.0.0:7777`; make startup and SIGINT/SIGTERM close both safely, failing loudly if either bind fails.
7. Update `Dockerfile.server` and release deployment: document both container ports, remove token secret transfer and required checks, create/validate `trade-message-center-summary` as an internal network, attach Center after every launch/restore, and leave only `7878` published. Preserve atomic env/image rollback behavior.
8. Update public API documentation and create the Mind handoff document. State that Mind deployment must join the network, use `trade-message-center:7777`, omit the old headers/credentials, and coordinate rollback.
9. Remove or rewrite summary-specific tests and add focused public/private/runtime/release-contract tests. Do not change repository, cursor, projection, migration, WebSocket, or generic Mind authorization tests except for imports/expectations invalidated by the removed summary feature.
10. Run `detect_changes()` before any commit, inspect the diff for unintentional public exposure, duplicated history logic, ownership regressions, silent fallbacks, and secrets/log leaks.
## Validation
1. Focused Node tests for config/app/entry/public HTTP/internal summary HTTP and summary-removal assertions, each with a 60-second timeout.
2. `pnpm typecheck`, `pnpm build`, `pnpm test`, `pnpm format:check`, and `git diff --check`.
3. Verify the release script's network creation/attachment branches and both rollback paths by static/unit coverage where possible; run workflow syntax validation if the available tooling supports it.
4. External production-like validation by the deployment owner: `docker network inspect trade-message-center-summary`, `docker port trade-message-center`, and an actual Mind-container request to `http://trade-message-center:7777/...`. Record Docker access as unverified if unavailable.
## Rollback
- Revert Center image and its release workflow/env together. The old image has no internal `7777` handler, so the Mind owner must revert its base URL/headers in the same change window.
- Keep the named Docker network intact; it is shared infrastructure and deleting it is outside this task.
@@ -0,0 +1,42 @@
# Center 摘要内部网络入口
## Goal
用同一 Docker daemon 中受限成员的私有网络,替代 Center 摘要历史读取的 Bearer、共享 token 与 Mind `authorize-summary` 回调。Mind 只经 Center 的内部 `7777` 监听器读取摘要;既有面向扩展和页面的 `7878` 服务保持不变。
## 已确认事实
- 已归档任务 `09-07-onetalk-summary-read-authorization` 为摘要 history GET 增加了 Bearer、`X-Mind-Purpose``X-Mind-Workspace-Id``TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN` 与 Center→Mind `POST /internal/bright/onetalk/authorize-summary`;这些仅是摘要专用机制。
- 当前 `startServer` 只调用一次 `app.listen({ host: config.host, port: config.port })``createApp` 将 public HTTP、WebSocket、页面/插件授权和 summary route 装配为同一 Fastify app。
- 当前 release workflow 只以 `docker run --publish 7878:7878` 运行 Center,未创建或加入供 Mind 共用的命名网络。
- 用户已确认:Mind 与 Center 将运行在同一个 Docker daemon;摘要读取的权限边界改为该私有网络的成员资格。该边界不提供 workspace/conversation 级授权,具备该网络或 Docker daemon 管理权限的主体均可访问摘要接口。
## Requirements
- R1:保留 public `7878` listener、Cookie 页面授权、扩展 WebSocket、普通读取、发送、health 和现有发布端口;不得以改写唯一 `PORT` 的方式把整个 Bright 服务迁至 7777。
- R2:新增独立 `7777` internal listener,只暴露摘要 history GETpublic listener 的既有 health check 保持不变。internal handler 直接选择 summary read,继续使用既有固定 `[fromSentAtMs,toSentAtMs)`、opaque cursor、`history_incomplete` 和只读 service 语义。
- R3internal listener 不读取或校验 summary Bearer、`X-Mind-Purpose``X-Mind-Workspace-Id`,也不回调 Mind 归属服务。摘要响应只返回会话、消息与分页数据,不得伪造或回显 workspace/mind user/bindingMind 自己持有调用的 workspace 上下文。
- R4:删除仅由摘要互信引入的 Center contract、credential/config、callback client、diagnostic、测试、示例环境变量和 release secret 注入。保留供 public 页面与插件使用的通用 Mind authorization reader、其 `MIND_AUTH_BASE_URL` 配置与 WebSocket 授权。
- R5:发布在一个仅含 Mind 与 Center 的命名 Docker `internal` 网络上提供 `7777`;Center 先加入仅供其自身 egress/public port 的命名 bridge network,再挂载该 internal network,不能留在 Docker 默认 bridge。Center 仍发布 `7878`,绝不 `--publish`/Compose `ports` 映射 `7777`。内部监听必须绑定 `0.0.0.0:7777`,以供对等容器访问;镜像 `EXPOSE` 不得被误当作访问控制。
- R6:网络成员资格是唯一摘要访问控制。不得添加 token、Cookie fallback、IP allowlist、workspace header 或从 Center 数据行反推 workspace 的替代机制;也不得将无鉴权摘要路由注册到 public listener。
- R7:交付给 Mind 的对接文档,固定内部 URL、无认证请求形状、网络名称/加入方式、不可用边界、部署验收命令和明确的 Docker-daemon 前提;不在本仓库修改 Mind 代码或其部署。
## Acceptance Criteria
- [ ] Mind 容器在 `trade-message-center-summary` 网络中,能不带 Bearer 和 Mind workspace/purpose header 访问 Center `:7777` 的摘要 history;固定时间窗、分页、`history_incomplete` 和数据库只读行为保持既有契约。
- [ ] 同一无鉴权请求通过 public `:7878` 不会获得摘要消息;public listener 的 Cookie 授权、CORS、列表/详情/消息、WebSocket、发送和 health 回归通过。
- [ ] Center 运行时源码、配置、示例 env、CI/release 和活跃测试不再引用 summary token、`authorize-summary` 或摘要 callback;通用 Mind authorization 和页面/插件授权仍可用。
- [ ] 发布脚本创建/复用受限命名网络、将 Center 接入它,并且只发布 `7878`。部署验收用 `docker network inspect``docker port` 和真实 Mind→Center internal HTTP 请求证明网络可达与 `7777` 未对宿主机发布。
- [ ] Mind 对接文档可让其所有者在同一 Docker daemon 将 Mind 容器加入私有网络、改用内部 `7777` URL,且明确此网络成员可读取任意摘要 account/conversation,不应保留 Bearer/workspace/purpose header。
- [ ] 目标单测、typecheck、build、格式检查和 diff review 通过;真实双容器部署验证若当前环境不可得,必须以外部未验证项报告,不能用单机 mock 代替。
## Out of Scope
- 在本仓库修改 Mind 的业务读取逻辑、数据库 schema 或其部署仓库;Mind 将请求内部 URL 并加入命名网络的改动由其所有者交付。
- workspace、账号或会话级授权;它们与“网络成员即授权”的已确认边界冲突。
-`7777` 暴露给宿主机、互联网、跨 Docker daemon、Kubernetes 或 Docker Swarm 网络;若 Mind 与 Center 不在同一 Docker daemon,本设计不适用。
- 数据库迁移、消息投影、历史 cursor/排序语义、通用 Mind authorization 的重构。
## Delivery Boundary
Center 的 release workflow 负责创建/复用私有网络并把 Center 接入其中。Mind 所有者依据本任务交付的文档,将其容器接入同一网络并改用 internal URL;该外部操作是端到端部署验收的前置条件,不是本仓库的代码变更。
@@ -0,0 +1,26 @@
{
"id": "onetalk-summary-internal-network",
"name": "onetalk-summary-internal-network",
"title": "Center 摘要内部网络入口",
"description": "以同 Docker 内部网络的 7777 摘要入口替代摘要专用 Bearer 与 Mind 回调授权。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-07",
"completedAt": null,
"branch": "codex/onetalk-summary-read-authorization",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+1 -1
View File
@@ -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))"
+2 -10
View File
@@ -56,7 +56,6 @@ export const createOneTalkHarnessHtml = (
<label>OneTalk 账号 ID<input id="account-id" autocomplete="off" placeholder="onetalk-account-1" required></label>
<label>会话查询<input id="conversation-query" autocomplete="off" placeholder="名称或会话 ID"></label>
<label>会话<select id="conversation-id" disabled><option value="">先加载会话</option></select></label>
<label>读取目的<select id="read-purpose"><option value="normal">普通读取</option><option value="communication_summary_read">纪要读取</option></select></label>
<label>起始时间(含)<input id="from-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
<label>结束时间(不含)<input id="to-sent-at-ms" inputmode="numeric" placeholder="epoch milliseconds"></label>
<label>开发 Cookie<input id="mind-cookie" autocomplete="off" placeholder="mind_session=opaque"></label>
@@ -71,7 +70,7 @@ export const createOneTalkHarnessHtml = (
<label>发送内容<input id="send-content" autocomplete="off" placeholder="输入要发送的文本"></label>
<button id="send" class="secondary" type="button" disabled>发送</button>
</div>
<p class="hint">HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor;纪要读取会发送 X-Mind-Purpose 并要求完整时间窗。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
<p class="hint">HTTP 列表和历史分页都只保存并回传 Bright 返回的 opaque cursor。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
</section>
<section class="panel" aria-labelledby="status-heading">
@@ -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');
-1
View File
@@ -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";
@@ -1,90 +0,0 @@
// 定义纪要历史读取的专用授权契约
export const ONETALK_SUMMARY_READ_PURPOSE = "communication_summary_read" as const;
export type OneTalkSummaryReadScope = {
workspaceId: string;
channelAccountId: string;
conversationId: string;
};
export type OneTalkSummaryReadAuthorizationRequest = OneTalkSummaryReadScope & {
purpose: typeof ONETALK_SUMMARY_READ_PURPOSE;
};
export type OneTalkSummaryReadAuthorization = {
purpose: typeof ONETALK_SUMMARY_READ_PURPOSE;
scope: OneTalkSummaryReadScope;
permissions: ["read"];
};
export type OneTalkSummaryReadAuthorizationRejection = {
code:
| "auth_required"
| "invalid_request"
| "scope_forbidden"
| "summary_workspace_disabled"
| "authorization_unavailable";
};
export type OneTalkSummaryReadAuthorizationDecodeResult =
| { ok: true; authorization: OneTalkSummaryReadAuthorization }
| { ok: false; rejection?: OneTalkSummaryReadAuthorizationRejection };
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean =>
Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isIdentifier = (value: unknown): value is string =>
typeof value === "string" &&
value.length > 0 &&
value === value.trim() &&
!/[\u0000-\u001f\u007f]/u.test(value);
/** 解码 Mind 的精确纪要授权响应,未知形状一律拒绝。 */
export const decodeOneTalkSummaryReadAuthorization = (
value: unknown,
): OneTalkSummaryReadAuthorizationDecodeResult => {
if (!isRecord(value)) return { ok: false };
if (hasExactKeys(value, ["code"]) && typeof value.code === "string") {
if (
value.code === "auth_required" ||
value.code === "invalid_request" ||
value.code === "scope_forbidden" ||
value.code === "summary_workspace_disabled" ||
value.code === "authorization_unavailable"
) {
return { ok: false, rejection: { code: value.code } };
}
return { ok: false };
}
if (!hasExactKeys(value, ["purpose", "scope", "permissions"]) || !isRecord(value.scope)) {
return { ok: false };
}
if (
value.purpose !== ONETALK_SUMMARY_READ_PURPOSE ||
!hasExactKeys(value.scope, ["workspaceId", "channelAccountId", "conversationId"]) ||
!isIdentifier(value.scope.workspaceId) ||
!isIdentifier(value.scope.channelAccountId) ||
!isIdentifier(value.scope.conversationId) ||
!Array.isArray(value.permissions) ||
value.permissions.length !== 1 ||
value.permissions[0] !== "read"
) {
return { ok: false };
}
return {
ok: true,
authorization: {
purpose: ONETALK_SUMMARY_READ_PURPOSE,
scope: {
workspaceId: value.scope.workspaceId,
channelAccountId: value.scope.channelAccountId,
conversationId: value.scope.conversationId,
},
permissions: ["read"],
},
};
};
@@ -1,42 +0,0 @@
// 验证纪要读取授权协议的严格解码边界
import assert from "node:assert/strict";
import test from "node:test";
import {
decodeOneTalkSummaryReadAuthorization,
ONETALK_SUMMARY_READ_PURPOSE,
} from "../src/index.ts";
const authorization = {
purpose: ONETALK_SUMMARY_READ_PURPOSE,
scope: {
workspaceId: "803937a7-f7d3-497d-a5ec-b99d0314669e",
channelAccountId: "account-1",
conversationId: "conversation-1",
},
permissions: ["read"],
};
test("decodes only the exact summary read grant shape", () => {
assert.deepEqual(decodeOneTalkSummaryReadAuthorization(authorization), {
ok: true,
authorization,
});
for (const value of [
{ ...authorization, permissions: ["read", "send"] },
{ ...authorization, scope: { ...authorization.scope, mindUserId: "invented" } },
{ ...authorization, purpose: "other" },
{ ...authorization, unexpected: true },
]) {
assert.deepEqual(decodeOneTalkSummaryReadAuthorization(value), { ok: false });
}
});
test("decodes only registered summary authorization rejections", () => {
assert.deepEqual(decodeOneTalkSummaryReadAuthorization({ code: "scope_forbidden" }), {
ok: false,
rejection: { code: "scope_forbidden" },
});
assert.deepEqual(decodeOneTalkSummaryReadAuthorization({ code: "unknown" }), { ok: false });
});
-30
View File
@@ -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,
});
-18
View File
@@ -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<string, string | undefined>,
): SummaryReadAuthorizationConfig | undefined => {
const value = environment.TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN;
if (value === undefined || value === "") return undefined;
if (!isSummaryReadToken(value)) {
throw new Error("Invalid TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN");
}
return { token: value };
};
const parseChromeExtensionDownload = (
environment: Record<string, string | undefined>,
): ChromeExtensionDownloadConfig | undefined => {
@@ -162,6 +145,5 @@ export const loadConfig = (environment: Record<string, string | undefined>): Ser
environment,
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
),
summaryReadAuthorization: parseSummaryReadAuthorization(environment),
};
};
+4 -10
View File
@@ -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<void> => {
const config = loadConfig(readServerEnvironment());
const app = createApp(config, {
const runtime = createServerRuntime(config, {
onOneTalkDiagnostic,
onMindAuthorizationDiagnostic,
onSummaryAuthorizationDiagnostic,
});
const shutdown = async (): Promise<void> => {
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;
}
};
+84
View File
@@ -0,0 +1,84 @@
// 提供仅供 Docker 内部网络访问的摘要历史 HTTP 边界
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import {
ONETALK_HISTORY_ROUTE,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import {
ONETALK_SUMMARY_READ_PURPOSE,
type CenterMessage,
type OneTalkReadService,
} from "../onetalk/index.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
import { replyWithHistoryRead, sendError, type BrightHistoryQuery } from "./onetalk.ts";
type SummaryHistoryParams = {
channelAccountId: string;
conversationId: string;
};
export type InternalSummaryHistoryResponse = {
conversationId: string;
messages: CenterMessage[];
page: {
hasMore: boolean;
nextCursor: string | null;
};
};
export type InternalSummaryRouteOptions = {
readService: OneTalkReadService;
cutoverPolicy: OneTalkCutoverPolicy;
};
/** 注册不含浏览器授权或 CORS 的内部摘要 history 路由。 */
export const installInternalSummaryRoute = (
app: FastifyInstance,
options: InternalSummaryRouteOptions,
): void => {
const requestEpochs = new WeakMap<FastifyRequest, number>();
const requestIsAdmitted = (request: FastifyRequest): boolean => {
const epoch = requestEpochs.get(request);
return (
epoch !== undefined &&
options.cutoverPolicy.isCurrent(epoch) &&
options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)
);
};
const internalAdmissionGuard = async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> => {
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
return void sendError(reply, 503, "authorization_unavailable");
}
requestEpochs.set(request, options.cutoverPolicy.capture());
};
app.get<{ Params: SummaryHistoryParams; Querystring: BrightHistoryQuery }>(
ONETALK_HISTORY_ROUTE,
{ preHandler: internalAdmissionGuard },
async (request, reply) => {
reply.header("cache-control", "no-store");
if (!requestIsAdmitted(request)) {
return sendError(reply, 503, "authorization_unavailable");
}
return replyWithHistoryRead(reply, {
readService: options.readService,
scope: { channelAccountId: request.params.channelAccountId },
conversationId: request.params.conversationId,
query: request.query,
purpose: ONETALK_SUMMARY_READ_PURPOSE,
isAdmitted: () => requestIsAdmitted(request),
toResponse: (result) =>
({
conversationId: result.conversationId,
messages: result.messages,
page: result.page,
}) satisfies InternalSummaryHistoryResponse,
});
},
);
};
+94 -265
View File
@@ -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<AuthorizedSummaryReadScopeResult> => {
const workspaceId = summaryWorkspaceIdFor(request);
if (request.headers["x-mind-purpose"] !== SUMMARY_PURPOSE || workspaceId === null) {
return {
ok: false,
response: sendError(reply, 403, "authorization_rejected"),
};
}
let decision: Awaited<ReturnType<SummaryAuthorizationReader["authorize"]>>;
try {
decision = await authorization.authorize(
{
purpose: SUMMARY_PURPOSE,
workspaceId,
channelAccountId,
conversationId,
},
typeof request.headers.authorization === "string"
? request.headers.authorization
: undefined,
request.id,
);
} catch {
return {
ok: false,
response: sendError(reply, 503, "authorization_unavailable"),
};
}
if (!decision.allowed) {
const statusCode =
decision.code === "auth_required"
? 401
: decision.code === "authorization_unavailable"
? 503
: 403;
return { ok: false, response: sendError(reply, statusCode, decision.code) };
}
return { ok: true, scope: decision.authorization.scope };
};
const applyCors = (reply: FastifyReply, request: FastifyRequest, origin?: string): void => {
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<OneTalkMindScope, "channelAccountId">;
conversationId: string;
query: BrightHistoryQuery;
purpose: OneTalkHistoryReadPurpose;
isAdmitted: () => boolean;
toResponse: (result: HistoryReadSuccess) => unknown;
};
/** 执行历史读取并映射共享的参数、分页与稳定错误语义。 */
export const replyWithHistoryRead = async (
reply: FastifyReply,
options: HistoryReadReplyOptions,
): Promise<FastifyReply> => {
const fromSentAtMs = parseOptionalSafeInteger(options.query.fromSentAtMs);
const toSentAtMs = parseOptionalSafeInteger(options.query.toSentAtMs);
const limit = parseLimit(options.query.limit);
const cursor = parseOpaqueCursor(options.query.cursor);
if (fromSentAtMs === null || toSentAtMs === null) {
return sendError(reply, 400, "invalid_time_range");
}
if (fromSentAtMs !== undefined && toSentAtMs !== undefined && fromSentAtMs >= toSentAtMs) {
return sendError(reply, 400, "invalid_time_range");
}
if (
options.purpose === "communication_summary_read" &&
(fromSentAtMs === undefined || toSentAtMs === undefined)
) {
return sendError(reply, 400, "invalid_time_range");
}
if (limit === null) return sendError(reply, 400, "invalid_limit");
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
try {
const result = await options.readService.readHistory({
scope: options.scope,
conversationId: options.conversationId,
...(fromSentAtMs === undefined ? {} : { fromSentAtMs }),
...(toSentAtMs === undefined ? {} : { toSentAtMs }),
...(limit === undefined ? {} : { limit }),
...(cursor === undefined ? {} : { cursor }),
purpose: options.purpose,
});
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
if (result.status === "not_found") return sendError(reply, 404, "conversation_not_found");
if (result.status === "rejected") {
if (result.reason === "history_incomplete") {
reply.header("retry-after", HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS);
return sendError(reply, 503, result.reason);
}
return sendError(reply, 400, result.reason);
}
return reply.send(options.toResponse(result));
} catch (error: unknown) {
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
}
};
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
@@ -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,
});
},
);
};
+2
View File
@@ -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";
+3 -1
View File
@@ -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 继续读取当前页之前的更旧消息。 */
+2 -1
View File
@@ -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" };
}
+64
View File
@@ -0,0 +1,64 @@
// 组合 public 与内部摘要两个 Fastify 监听实例
import Fastify, { type FastifyInstance } from "fastify";
import { createApp, type AppDependencies } from "./app.ts";
import type { ServerConfig } from "./config.ts";
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
import { createDatabase, type DatabaseConnection } from "./database/index.ts";
import { installInternalSummaryRoute } from "./http/onetalk-summary.ts";
import { createOneTalkReadRepository, createOneTalkReadService } from "./onetalk/index.ts";
import type { OneTalkReadService } from "./onetalk/index.ts";
export const INTERNAL_SUMMARY_HOST = "0.0.0.0";
export const INTERNAL_SUMMARY_PORT = 7777;
export type ServerRuntime = {
publicApp: FastifyInstance;
internalSummaryApp: FastifyInstance;
listen: () => Promise<void>;
close: () => Promise<void>;
};
export type ServerRuntimeDependencies = AppDependencies & {
summaryHost?: string;
summaryPort?: number;
};
/** 创建共享读取事实与独立网络边界的双监听运行时。 */
export const createServerRuntime = (
config: ServerConfig,
dependencies: ServerRuntimeDependencies = {},
): ServerRuntime => {
const database = dependencies.database ?? createDatabase(config.databaseUrl);
const readService =
dependencies.readService ??
createOneTalkReadService(createOneTalkReadRepository(database.db));
const cutoverPolicy = dependencies.cutoverPolicy ?? createOneTalkCutoverPolicy();
const publicApp = createApp(config, {
...dependencies,
database,
readService,
cutoverPolicy,
});
const internalSummaryApp = Fastify({ logger: false });
installInternalSummaryRoute(internalSummaryApp, { readService, cutoverPolicy });
const summaryHost = dependencies.summaryHost ?? INTERNAL_SUMMARY_HOST;
const summaryPort = dependencies.summaryPort ?? INTERNAL_SUMMARY_PORT;
const close = async (): Promise<void> => {
await internalSummaryApp.close();
await publicApp.close();
};
const listen = async (): Promise<void> => {
try {
await publicApp.listen({ host: config.host, port: config.port });
await internalSummaryApp.listen({ host: summaryHost, port: summaryPort });
} catch (error: unknown) {
await close();
throw error;
}
};
return { publicApp, internalSummaryApp, listen, close };
};
-230
View File
@@ -1,230 +0,0 @@
// 调用 Mind 纪要授权并验证服务凭据
import { createHash, timingSafeEqual } from "node:crypto";
import {
decodeOneTalkSummaryReadAuthorization,
ONETALK_SUMMARY_READ_PURPOSE,
type OneTalkSummaryReadAuthorization,
type OneTalkSummaryReadAuthorizationRequest,
type OneTalkSummaryReadScope,
} from "@trade-message-center/onetalk-contract";
import { readSummaryReadBearerToken } from "./summary-credential.ts";
export const MIND_SUMMARY_AUTHORIZATION_PATH = "/internal/bright/onetalk/authorize-summary";
export type SummaryAuthorizationCode =
| "auth_required"
| "authorization_rejected"
| "authorization_unavailable"
| "scope_mismatch";
export type SummaryAuthorizationDecision =
| { allowed: true; authorization: OneTalkSummaryReadAuthorization }
| { allowed: false; code: SummaryAuthorizationCode };
type SummaryAuthorizationRejected = Extract<SummaryAuthorizationDecision, { allowed: false }>;
export type SummaryAuthorizationDiagnostic = {
event: "onetalk_summary_authorization";
requestId: string;
stage: "credential" | "callback" | "read";
outcome:
| "request_started"
| "allowed"
| "rejected"
| "unavailable"
| "succeeded"
| "failed"
| "waiting";
code?: string;
durationMs: number;
};
export type SummaryAuthorizationDiagnosticsSink = (event: SummaryAuthorizationDiagnostic) => void;
export type SummaryAuthorizationClientConfig = {
baseUrl: string;
token?: string;
timeoutMs: number;
fetch?: typeof fetch;
onDiagnostic?: SummaryAuthorizationDiagnosticsSink;
};
export type SummaryAuthorizationReader = {
authorize: (
request: OneTalkSummaryReadAuthorizationRequest,
authorizationHeader: string | undefined,
requestId: string,
) => Promise<SummaryAuthorizationDecision>;
};
export const reportSummaryAuthorizationDiagnostic = (
sink: SummaryAuthorizationDiagnosticsSink | undefined,
event: SummaryAuthorizationDiagnostic,
): void => {
try {
sink?.(event);
} catch {
// Diagnostics are observational and must never change authorization behavior.
}
};
const digest = (value: string): Buffer => createHash("sha256").update(value).digest();
const hasExpectedCredential = (authorizationHeader: string | undefined, token: string): boolean => {
const candidate = readSummaryReadBearerToken(authorizationHeader);
return candidate !== null && timingSafeEqual(digest(token), digest(candidate));
};
const sameScope = (left: OneTalkSummaryReadScope, right: OneTalkSummaryReadScope): boolean =>
left.workspaceId === right.workspaceId &&
left.channelAccountId === right.channelAccountId &&
left.conversationId === right.conversationId;
const rejectionFor = (
status: number,
code: string | undefined,
): SummaryAuthorizationRejected | null => {
if (status === 401 && code === "auth_required") {
return { allowed: false, code: "auth_required" };
}
if (status === 403 && code === "scope_forbidden") {
return { allowed: false, code: "scope_mismatch" };
}
if (
(status === 403 && code === "summary_workspace_disabled") ||
(status === 400 && code === "invalid_request")
) {
return { allowed: false, code: "authorization_rejected" };
}
if (status === 503 && code === "authorization_unavailable") {
return { allowed: false, code: "authorization_unavailable" };
}
return null;
};
const unavailable = (): SummaryAuthorizationRejected => ({
allowed: false,
code: "authorization_unavailable",
});
/** 创建缺少专用配置时明确拒绝的 summary reader。 */
export const createUnavailableSummaryAuthorizationReader = (
diagnostics?: SummaryAuthorizationDiagnosticsSink,
): SummaryAuthorizationReader => ({
authorize: async (_request, _authorizationHeader, requestId) => {
reportSummaryAuthorizationDiagnostic(diagnostics, {
event: "onetalk_summary_authorization",
requestId,
stage: "credential",
outcome: "unavailable",
code: "authorization_unavailable",
durationMs: 0,
});
return unavailable();
},
});
/** 创建只用于消息历史读取的 Mind 纪要授权 reader。 */
export const createSummaryAuthorizationReader = (
config: SummaryAuthorizationClientConfig,
): SummaryAuthorizationReader => {
const fetchImplementation = config.fetch ?? fetch;
const authorizationUrl = new URL(MIND_SUMMARY_AUTHORIZATION_PATH, config.baseUrl);
return {
authorize: async (request, authorizationHeader, requestId) => {
const startedAt = Date.now();
if (config.token === undefined) {
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "credential",
outcome: "unavailable",
code: "authorization_unavailable",
durationMs: Date.now() - startedAt,
});
return unavailable();
}
if (
authorizationHeader === undefined ||
!hasExpectedCredential(authorizationHeader, config.token)
) {
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "credential",
outcome: "rejected",
code: "auth_required",
durationMs: Date.now() - startedAt,
});
return { allowed: false, code: "auth_required" };
}
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "callback",
outcome: "request_started",
durationMs: Date.now() - startedAt,
});
let response: Response;
try {
response = await fetchImplementation(authorizationUrl, {
method: "POST",
headers: {
authorization: authorizationHeader,
"content-type": "application/json",
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(config.timeoutMs),
redirect: "error",
});
} catch {
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "callback",
outcome: "unavailable",
code: "authorization_unavailable",
durationMs: Date.now() - startedAt,
});
return unavailable();
}
let decoded: ReturnType<typeof decodeOneTalkSummaryReadAuthorization>;
try {
decoded = decodeOneTalkSummaryReadAuthorization(await response.json());
} catch {
decoded = { ok: false };
}
if (
response.status === 200 &&
decoded.ok &&
sameScope(request, decoded.authorization.scope)
) {
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "callback",
outcome: "allowed",
durationMs: Date.now() - startedAt,
});
return { allowed: true, authorization: decoded.authorization };
}
const rejection = !decoded.ok
? rejectionFor(response.status, decoded.rejection?.code)
: null;
const decision = rejection ?? unavailable();
reportSummaryAuthorizationDiagnostic(config.onDiagnostic, {
event: "onetalk_summary_authorization",
requestId,
stage: "callback",
outcome: decision.code === "authorization_unavailable" ? "unavailable" : "rejected",
code: !decoded.ok ? (decoded.rejection?.code ?? decision.code) : "scope_mismatch",
durationMs: Date.now() - startedAt,
});
return decision;
},
};
};
-18
View File
@@ -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;
};
+42 -34
View File
@@ -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" }),
+147 -175
View File
@@ -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<typeof createApp>): Promise<void> => {
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<OneTalkReadService["readHistory"]>[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<void>((resolve) => callback.listen(0, "127.0.0.1", resolve));
const callbackPort = (callback.address() as AddressInfo).port;
const app = createApp(
{
...testConfig,
mindAuthorization: {
...testConfig.mindAuthorization,
baseUrl: `http://127.0.0.1:${callbackPort}`,
test("serves internal summary history without credentials or a returned authorization scope", async () => {
let received: Parameters<OneTalkReadService["readHistory"]>[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<void>((resolve, reject) =>
callback.close((error) => (error ? reject(error) : resolve())),
);
}
});
test("does not allow summary credentials to reach list, detail, or Cookie authorization", async () => {
let pageAuthorizationCalls = 0;
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async () => {
pageAuthorizationCalls += 1;
return { allowed: false, code: "auth_required" };
},
readAuthorizationVersion: async () => "unused",
},
readService: createReadService(),
});
try {
for (const url of [conversationsUrl(), conversationUrl()]) {
const response = await app.inject({
method: "GET",
url,
headers: {
...headers(),
authorization: `Bearer ${summaryToken}`,
"x-mind-workspace-id": summaryWorkspaceId,
},
});
assert.equal(response.statusCode, 403);
assert.deepEqual(response.json(), { error: { code: "authorization_rejected" } });
}
assert.equal(pageAuthorizationCalls, 0);
} finally {
await closeApp(app);
}
});
test("fails closed when the summary authorization dependency throws", async () => {
let pageAuthorizationCalls = 0;
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async () => {
pageAuthorizationCalls += 1;
return { allowed: false, code: "auth_required" };
},
readAuthorizationVersion: async () => "unused",
},
summaryAuthorization: {
authorize: async () => {
throw new Error("summary authorization dependency secret");
},
},
readService: createReadService(),
});
try {
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);
@@ -1,194 +0,0 @@
// 验证纪要服务凭据和 Mind 回调的失败关闭边界
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import test from "node:test";
import { ONETALK_SUMMARY_READ_PURPOSE } from "@trade-message-center/onetalk-contract";
import {
createSummaryAuthorizationReader,
MIND_SUMMARY_AUTHORIZATION_PATH,
} from "../src/summary-authorization.ts";
const token = "summary-read-token-with-at-least-thirty-two-characters";
const request = {
purpose: ONETALK_SUMMARY_READ_PURPOSE,
workspaceId: "803937a7-f7d3-497d-a5ec-b99d0314669e",
channelAccountId: "account-1",
conversationId: "conversation-1",
} as const;
const response = (status: number, body: unknown): Response =>
({ status, json: async () => body }) as Response;
test("sends the exact summary scope and no Cookie to the fixed Mind callback", async () => {
let url = "";
let init: RequestInit | undefined;
const reader = createSummaryAuthorizationReader({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
token,
fetch: async (input, requestInit) => {
url = String(input);
init = requestInit;
return response(200, {
purpose: ONETALK_SUMMARY_READ_PURPOSE,
scope: {
workspaceId: request.workspaceId,
channelAccountId: request.channelAccountId,
conversationId: request.conversationId,
},
permissions: ["read"],
});
},
});
const decision = await reader.authorize(request, `Bearer ${token}`, "request-1");
assert.equal(decision.allowed, true);
assert.equal(url, "https://mind.example.com" + MIND_SUMMARY_AUTHORIZATION_PATH);
assert.deepEqual(JSON.parse(String(init?.body)), request);
assert.equal((init?.headers as Record<string, string>).cookie, undefined);
assert.equal((init?.headers as Record<string, string>).authorization, `Bearer ${token}`);
assert.equal(init?.redirect, "error");
});
test("fails closed for missing config, bad credentials, invalid response, and scope mismatch", async () => {
const cases = [
{
config: { baseUrl: "https://mind.example.com", timeoutMs: 100 },
authorization: `Bearer ${token}`,
response: response(200, {}),
expected: "authorization_unavailable",
},
{
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
authorization: "Bearer wrong-token",
response: response(200, {}),
expected: "auth_required",
},
{
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
authorization: `Bearer ${token}`,
response: response(200, { purpose: ONETALK_SUMMARY_READ_PURPOSE }),
expected: "authorization_unavailable",
},
...(["workspaceId", "channelAccountId", "conversationId"] as const).map((field) => ({
config: { baseUrl: "https://mind.example.com", timeoutMs: 100, token },
authorization: `Bearer ${token}`,
response: response(200, {
purpose: ONETALK_SUMMARY_READ_PURPOSE,
scope: {
workspaceId: field === "workspaceId" ? "other-workspace" : request.workspaceId,
channelAccountId:
field === "channelAccountId" ? "other-account" : request.channelAccountId,
conversationId:
field === "conversationId" ? "other-conversation" : request.conversationId,
},
permissions: ["read"],
}),
expected: "authorization_unavailable",
})),
] as const;
for (const testCase of cases) {
const reader = createSummaryAuthorizationReader({
...testCase.config,
fetch: async () => testCase.response,
});
const decision = await reader.authorize(request, testCase.authorization, "request-1");
assert.deepEqual(decision, { allowed: false, code: testCase.expected });
}
});
test("uses the configured token68 grammar before comparing Bearer credentials", async () => {
let fetchCalls = 0;
const reader = createSummaryAuthorizationReader({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
token,
fetch: async () => {
fetchCalls += 1;
return response(200, {});
},
});
for (const authorization of [
"Bearer summary-read-token-with an-internal-space-and-length",
"Bearer summary-read-token-with,comma-and-length-value",
"Bearer summary-read-token-with=padding-in-the-middle-value",
]) {
assert.deepEqual(await reader.authorize(request, authorization, "request-1"), {
allowed: false,
code: "auth_required",
});
}
assert.equal(fetchCalls, 0);
});
test("fails closed for real callback timeout, redirect, invalid JSON, and 500", async () => {
let mode: "timeout" | "redirect" | "invalid-json" | "server-error" = "timeout";
const callback = createServer((_request, response) => {
if (mode === "timeout") {
setTimeout(() => response.destroy(), 50);
return;
}
if (mode === "redirect") {
response.writeHead(302, { location: "/redirect-target" });
response.end();
return;
}
if (mode === "invalid-json") {
response.writeHead(200, { "content-type": "text/html" });
response.end("<html>not-json</html>");
return;
}
response.writeHead(500, { "content-type": "application/json" });
response.end(JSON.stringify({ code: "authorization_unavailable" }));
});
await new Promise<void>((resolve) => callback.listen(0, "127.0.0.1", resolve));
const port = (callback.address() as AddressInfo).port;
try {
for (const nextMode of ["timeout", "redirect", "invalid-json", "server-error"] as const) {
mode = nextMode;
const reader = createSummaryAuthorizationReader({
baseUrl: `http://127.0.0.1:${port}`,
timeoutMs: nextMode === "timeout" ? 5 : 100,
token,
});
assert.deepEqual(
await reader.authorize(request, `Bearer ${token}`, `request-${nextMode}`),
{ allowed: false, code: "authorization_unavailable" },
);
}
} finally {
await new Promise<void>((resolve, reject) =>
callback.close((error) => (error ? reject(error) : resolve())),
);
}
});
test("maps only exact Mind status and rejection pairs", async () => {
const cases = [
[401, "auth_required", "auth_required"],
[403, "scope_forbidden", "scope_mismatch"],
[403, "summary_workspace_disabled", "authorization_rejected"],
[400, "invalid_request", "authorization_rejected"],
[503, "authorization_unavailable", "authorization_unavailable"],
[403, "auth_required", "authorization_unavailable"],
[200, "scope_forbidden", "authorization_unavailable"],
] as const;
for (const [status, code, expected] of cases) {
const reader = createSummaryAuthorizationReader({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
token,
fetch: async () => response(status, { code }),
});
assert.deepEqual(await reader.authorize(request, `Bearer ${token}`, "request-1"), {
allowed: false,
code: expected,
});
}
});
+5 -6
View File
@@ -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 },
});
+3 -22
View File
@@ -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 为 falseBright 返回:
```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)。
## 分页与错误
+41
View File
@@ -0,0 +1,41 @@
# OneTalk 内部纪要读取接口
> 本文交付给 trade-mind 所有者。它描述 Center 已发布的内部 Docker listenerMind 代码和部署改动不在本仓库。
## 前提与安全边界
Center 与 Mind 容器必须运行在同一 Docker daemon,并共同加入名为 `trade-message-center-summary` 的 Docker `internal` 网络。该网络的成员资格是唯一访问控制:加入网络的任意容器可读取任意已知 `channelAccountId + conversationId` 的摘要历史;它不提供 workspace、用户、账号或会话级授权,也不防护 Docker socket 持有者或宿主机 root。
Center 运行在仅供其公网发布与 egress 的 `trade-message-center-public` bridge 网络,并额外挂载 summary internal 网络;其它业务容器不得加入前者来访问 Center。Center 仍通过宿主机发布 public `7878`;内部 `7777` 绝不配置 Docker `--publish` 或 Compose `ports``EXPOSE 7777` 只是镜像元数据,不会对外发布端口。
## Mind 请求
```http
GET http://trade-message-center:7777/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages?fromSentAtMs=:inclusiveEpochMs&toSentAtMs=:exclusiveEpochMs&cursor=:opaqueCursor&limit=:1to100
```
- `fromSentAtMs``toSentAtMs` 必填,均为 safe integer,窗口为 `[fromSentAtMs,toSentAtMs)`,且前者必须小于后者。
- `cursor` 仅可原样回传上一页 `nextCursor``limit` 默认为 50,范围 1..100。
- 不发送 Cookie、`Authorization``X-Mind-Purpose``X-Mind-Workspace-Id`。Center 不读取这些头,也不会回调 Mind 的授权接口。
- 成功响应只含 `conversationId`、语义化 `messages``page`;没有 `scope``workspaceId``mindUserId`、binding 或授权版本。Mind 以自己的调用上下文关联 workspace。
- `historyComplete=false` 时返回 `503 { error: { code: "history_incomplete" } }``Retry-After: 30`。其它分页、内容投影和错误码与 [Bright OneTalk 会话读取 API](./bright-conversation-list-api.md) 的消息读取契约一致。
## 部署与验收
Center 的 release workflow 会创建或验证该网络为 `internal`,并在启动后将容器 `trade-message-center` 接入其中。Mind 部署所有者需要在同一 Docker daemon 执行等价操作:
```bash
docker network connect trade-message-center-summary <mind-container-name>
```
发布后至少验证:
```bash
docker network inspect trade-message-center-summary
docker network inspect trade-message-center-public
docker port trade-message-center
docker exec <mind-container-name> \
curl -fsS 'http://trade-message-center:7777/api/bright/onetalk/accounts/<account>/conversations/<conversation>/messages?fromSentAtMs=<from>&toSentAtMs=<to>&limit=1'
```
`docker port trade-message-center` 不得出现 `7777`。若 Center 回滚到不含内部 listener 的旧镜像,Mind 必须同时回滚其 base URL/请求头;共享网络不应在回滚时删除。