fix(server): authorize background OneTalk summary reads

This commit is contained in:
dongruiyang
2026-09-07 13:51:37 +08:00
parent 073f4e5ee4
commit 6e8eca944d
28 changed files with 1561 additions and 17 deletions
+2
View File
@@ -18,6 +18,8 @@ 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
+13 -2
View File
@@ -51,6 +51,7 @@ 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 }}
@@ -103,6 +104,9 @@ 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
@@ -237,6 +241,7 @@ 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 }}
IMAGE_TAG: ${{ github.sha }}
SOURCE_TAG: ${{ github.ref_name }}
@@ -287,6 +292,7 @@ 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}"
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
: "${DEPLOY_USER:?DEPLOY_USER is required}"
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
@@ -313,6 +319,7 @@ 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" \
@@ -321,10 +328,12 @@ 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") 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") 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")"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"$build_env bash -s" <<'BUILD_SCRIPT'
set -euo pipefail
@@ -343,7 +352,7 @@ jobs:
}
cleanup() {
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$SUMMARY_READ_TOKEN_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
}
trap cleanup EXIT
@@ -366,6 +375,7 @@ 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"
@@ -380,6 +390,7 @@ 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"
+1
View File
@@ -20,6 +20,7 @@
| [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 |
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
| [后台纪要专用只读授权](./summary-authorization.md) | 专用 Bearer、Mind 精确会话回调、scope 隔离与发布凭据 | 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 @@
## 当前状态
服务端目前没有日志库或结构化日志配置。启动入口仅在启动失败时输出不含连接串的错误文本;Fastify 应用测试关闭默认 logger。
服务端未引入额外日志库。后台纪要授权和读取使用下文限定的结构化诊断,由生产启动入口实际写入 stderr;其它诊断仍遵循各自注入边界。Fastify 默认 request logger 保持关闭
## 当前规则
@@ -47,3 +47,11 @@ 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,两者不能互相冒充验证。
@@ -0,0 +1,77 @@
# 后台纪要专用只读授权
## 1. Scope / Trigger
Mind 后台纪要任务通过 Bearer 读取 Center 单会话历史时应用本规范。Cookie 页面、plugin binding、WS 和发送仍由现有授权端口处理。Center 不连接 Mind 数据库,不从历史消息的 workspace/user 推导当前归属;Mind 是归属事实源。本契约无需数据库迁移。
## 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>
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"] }
```
## 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 信息。
## 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 |
| 历史未完整同步 | 503 history_incompleteRetry-After: 30 |
| 跨账号/会话/窗口 cursor | 400 invalid_cursor |
| 合法完整历史的空窗口 | 成功空消息页,不伪造历史完整性 |
## 5. Good / Base / Bad Cases
- 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);
```
@@ -0,0 +1,9 @@
{"file": ".trellis/spec/project/architecture.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/module-organization.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/module-ownership.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/source-file-conventions.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/missing-values.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/mind-authorization.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/logging-guidelines.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "本任务授权/代码质量规范"}
@@ -0,0 +1,38 @@
# Center summary 授权设计
## 根因与边界
现有 HTTP 仅通过 Cookie 调用 session authorization;后台请求带 Bearer/workspace,永远在消息查询前失败。属结构性权限边界修复;扩大现有 session reader 会把后台能力带到 WS/发送,所以单独建立 summary reader,只有 messages handler 注入使用。保留唯一 read service/repository,不新增存储或分页实现。
## Wire contract(依据当前 Mind 草稿)
- Mind -> Center: GET 既有 messages 路径;Authorization: Bearer <secret>、X-Mind-Purpose: communication_summary_read、X-Mind-Workspace-Id: <uuid>fromSentAtMs/toSentAtMs 必填、左闭右开、limit <= 100。
- Center -> Mind: POST 固定 MIND_AUTH_BASE_URL + /internal/bright/onetalk/authorize-summaryAuthorization 同一配置 secretJSON 恰为 {purpose,workspaceId,channelAccountId,conversationId},不转发 Cookie。
- 200: {purpose:"communication_summary_read",scope:{workspaceId,channelAccountId,conversationId},permissions:["read"]}。严格字段/类型/单一 read 权限解码,返回 scope 必须逐项匹配。
- Mind 稳定拒绝: 401 auth_required403 scope_forbidden / summary_workspace_disabled400 invalid_request503 authorization_unavailable。映射 Center 既有 auth_required / scope_mismatch / authorization_rejected / authorization_unavailable,明确记录安全诊断原因。未知状态/未知 code/不匹配状态、错误 JSON/redirect/timeout 均 503。
- 响应历史 scope 用真实 summary scope,不伪造 mindUserId。页面 scope 不变;只扩展 history 输入/响应类型,列表/详情/WS 不扩展。
## 选择与防护
- summary 选路只在 messages;有 Authorization 或后台 workspace 标记的尝试必须走独立分支并核对 purpose,不能鉴权失败后落回 Cookie。纯 Cookie + summary purpose 维持既有页面行为。
- 固定长度 SHA-256 digest + timingSafeEqual 比较凭据,Token 至少 32 字符且满足 ASCII token68`=` 仅可尾部);缺 Token 配置只关闭后台功能并在请求明确报 503,保留现有页面启动兼容;显式非法 Token 配置启动失败。
- 使用固定 Mind origin/timeout 配置,无新任意 URL、缓存、retry、额外权限事实源。每页重新回调授权。
- 保留 cutover epoch 在异步授权和读取后的 fencesummary scope 不可用于其它会话(类型/运行时按需要表达)。
- 诊断结构包含 requestId、stage/outcome、稳定 code、时长与读取结果,不记录原始 scope/URL/异常。入口输出有限结构 JSON 到 stderr,默认 Fastify request logger 仍不启用。
- Secret 由生产 GitHub secret 经 stdin 到构建机临时文件,再写受限 server.env;不能放进 SSH 命令行或镜像。发布要求显式配置;下一次发布沿正式注入链保留。
## 预计文件
- onetalk-contract:独立 summary authorization 契约/decoder 及包出口。
- server/config.ts、app.ts、entry.ts:配置、独立依赖装配、运行日志。
- server/http/onetalk.ts 及独立 summary authorization 模块:选路、token、回调和脱敏诊断。
- server/onetalk/read-model.ts(必要时 read-service.ts):history scope 支持,不改消息 SQL、排序和分页。
- .env.example、.github/workflows/release_ci.yml、相关 tests、docs 与 backend specs。
## 发布与限制
先使 Mind 回调可用且设置 workspace rollout 范围,再注入双方相同 secret 发布 Center。由 Mind 任务完成企业恢复、PG 纪要回读和页面展示;本分支不能把 Center mock/HTTP 测试称为生产恢复。回滚应用与 env 成对执行。
## 影响分析
GitNexus 1.6.6 index-only 刷新至当前分支基线,4176 nodes/9542 edges。upstream Function impact: loadConfig/createApp/createOneTalkReadService 各 0 direct LOWstartServer 1 direct/2 total LOWinstallOneTalkReadRoutes 1 direct LOWauthorizeRequestScope 1 direct/2 total LOW。图仍漏部分装配调用,人工核对 createApp -> HTTP/WSstartServer -> loadConfig/createApp;按 critical 风险验证。新增改动 symbol 仍需先 impact。
## Review convergence
独立检查发现配置允许内空格/逗号而 Bearer 读取拒绝,违反启动校验与实际凭据一致性。第 1 轮将配置与读取收敛至 server-local credential owner,并新增 token68 有效/非法回归。原有正则实际允许中间 `=`;该值是本次统一 ASCII token68 后新增明确拒绝,不能声称旧读取路径已拒绝。Scope mismatch 测试已修正为合法 shape 下逐项替换 workspace/account/conversation,避免额外 purpose 字段让测试只覆盖 decoder。
第 1 轮复验行为通过,但发现 token68 字符集在 predicate 与 Bearer 正则重复。第 2 轮仅让 Bearer 提取 value,再交唯一 predicate 校验;无新增行为范围。
@@ -0,0 +1,9 @@
{"file": ".trellis/spec/project/architecture.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/module-organization.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/module-ownership.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/source-file-conventions.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/project/missing-values.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/mind-authorization.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/logging-guidelines.md", "reason": "本任务授权/代码质量规范"}
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "本任务授权/代码质量规范"}
@@ -0,0 +1,12 @@
# 执行计划
模式 Default,风险 critical,单一实现 writer + 独立 final-integration checker。当前 checkout 是唯一 integration 工作区;不创建并行写 lane。
1. [x] 创建 codex/onetalk-summary-read-authorization;读取评估、spec、Mind wire;刷新图并 impact。
2. [x] 实现独立 summary contract/reader、HTTP 选路和 scope、配置装配与生产诊断,补安全/HTTP测试。
3. [x] 更新 env/release 发布注入与联调文档,补发布验证。
4. [x] 实现者格式化本次文件,目标单测 60s 硬超时,静态检查,再 build,再 loopback HTTP smoke;汇报真实结果。
5. [x] 独立 checker 审查完整 diff、授权边界、现有 Cookie/WS 回归和日志;至多两轮返修。
6. [x] 主代理核查 diff、更新规范/任务验证证据,交付分支。用户未要求 push/merge/deploy,本轮不执行。
验证:pnpm format:check / pnpm typecheck / pnpm buildserver 源码和构建单测均使用外部 60 秒硬超时;contract 测试;相关 release 测试。能使用隔离 PostgreSQL 则运行既有 integration,否则明确标注未验证。任何真实服务测试仅在本 integration 工作区进行。
@@ -0,0 +1,24 @@
# Center 后台纪要只读授权与发布配置
## Goal
修复 Mind 后台无法读取 Center 历史消息的服务授权缺口,交付独立分支内可审查、可验证的 Center 改动。
## Requirements
- 专用 Bearer 仅可用于 GET messagespurpose 必须为 communication_summary_read。
- workspace、账号、精确会话归属由 Mind 回调核验;不从请求头或历史消息归属推导授权,不伪造用户/binding。
- Token 错误、缺配置、回调拒绝/不可用/异常、scope 不一致必须明确拒绝,无 Cookie fallback。
- 原网页 Cookie 授权、CORS、WebSocket、发送与分页语义保持原样。
- summary 读取必须有固定 from/to,历史不完整仍返回 history_incomplete 与 Retry-After。
- 两端共用 TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKENCenter 示例和 release 流程可安全注入并保留配置。
- 生产入口实际接通脱敏 summary 诊断,覆盖鉴权、读取、成功/失败和耗时,不包含 Token/Cookie/正文。
- 本任务仅 Center 代码、测试、文档,不修改 Mind 仓库、数据库结构、生产配置或部署,不实现全局调度或企业恢复。
## Acceptance Criteria
- [x] 专用授权经真实 loopback HTTP 回调读取成功;精确作用域、凭据、错误 purpose、超时/非法响应拒绝矩阵通过。
- [x] Cookie 页面路径回归通过;Token 无列表、详情、发送或 WS 权限;失败不回落 Cookie。
- [x] 多页消息及固定时间窗沿用旧页向更旧翻页,history_incomplete 和跨 scope cursor 拒绝通过。
- [x] 配置/发布注入与日志脱敏验证通过。
- [x] 相关单测(60 秒硬超时)、格式化、typecheck、build 和独立集成检查通过;外部联调未做的部分明确标注。
## Source
用户提供评估:trade-mind/docs/reports/2026-09-07-onetalk-summary-recovery-assessment.md。实施发现 Mind 工作区已有 authorize-summary 草稿,按当前源码 wire 对齐。
@@ -0,0 +1,176 @@
# Research: Center summary read authorization
- Query: 评估 Center 纪要历史读取的专用 Bearer 授权调用链、跨仓 wire 字段、权限隔离陷阱、部署注入和验证入口。
- Scope: mixedCenter 内部源码与规范;trade-mind 当前工作树和跨仓接口规范)
- Date: 2026-09-07
- Coordination class: cross-cutting
- Blocks: apps/server 的 HTTP 授权分支、Mind 回调 client/contract、config/app/entry 注入、发布 workflow 和测试必须先统一摘要 scope 契约;读服务的 scope 类型若发生变化,必须先确定共享边界。
- Shared boundary: Center apps/server/src/http/onetalk.ts、apps/server/src/mind-authorization.ts、apps/onetalk-contract/src/authorization.ts/model.ts、apps/server/src/config.ts/app.ts/entry.ts,以及 Mind authorize-summary 回调、summary client 和部署 env。
## Findings
### 已核实的 Center 调用链
- apps/server/src/http/onetalk.ts:25,395-467 已有 communication_summary_read purpose;摘要读取和普通读取共用 /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages,且摘要要求固定 fromSentAtMs、toSentAtMs。当前所有三个 GET 路由都先进入 authorizeRequestScope,摘要目前仍只读取 Cookie 并调用完整的 Mind page reader。
- apps/server/src/http/onetalk.ts:104-124,141-192 的 BrightReadRouteOptions.authorization 和 authorizeRequestScope 只接受完整 OneTalkMindScope(含 mindUserId/workspaceId/channelAccountId)及 read 权限。把 Bearer 摘要结果塞进这个 reader 会迫使实现伪造 binding 或 mindUserId,破坏归属校验。
- apps/server/src/http/onetalk.ts:313-394 的会话列表和详情路由也使用同一完整 Cookie reader;摘要 Token 必须只在历史 GET 分支、且 purpose 精确匹配时可用。不能把摘要 reader 替换 options.authorization,否则会扩大到 list/detail。
- apps/server/src/http/onetalk.ts:201-311 先做 cutover admission、Origin/CORS 和 request epochinstallOneTalkReadRoutes 在授权前、读服务返回后和异常路径都会重新检查 admission。摘要分支需要保持这些检查,并等待远端授权完成后再进入数据库读取。
- apps/server/src/http/onetalk.ts:246 的 summaryPurposeFor 把未知 purpose 映射为 normal。若请求带 Bearer 而 purpose 缺失或拼错,不能依靠这个映射获得普通 Cookie 语义或绕过摘要授权;应在摘要凭据分支对 purpose 做严格判定,并让其他 purpose 继续使用原 Cookie 路径。
- apps/server/src/onetalk/read-service.ts:157-220 已拥有摘要 purpose、固定窗口、history_incomplete 和 opaque cursorapps/server/src/onetalk/read-repository.ts:250-302 按 account/conversation/time/key 过滤,数据库 DESC 取页,service 反转成 API ASC。摘要授权应在 HTTP 边界完成,不应在 repository 复制权限逻辑。
- apps/server/src/onetalk/read-service.ts:70-81 的 cursor 绑定 account、conversation、from、toapps/server/src/onetalk/read-cursor.ts:5-7,68-76,125-166 使用 v2 “向更旧消息翻页”的格式。鉴权分支不能放宽 cursor 或时间窗口,否则会改变已有完整读取不变量。
- apps/server/src/http/onetalk.ts:213-216,455-460 的响应/插件状态目前需要完整 scope。摘要 response 的 scope 必须是独立 DTO(至少 workspace/account;是否回显 conversation 要与 Mind contract 明确一致),不能用 pluginStatusFor 或完整 Mind scope 生成伪造的用户绑定。
### 共享授权契约与归属事实
- apps/onetalk-contract/src/authorization.ts:15-78,101-133 的 MindAuthorizationResponse 是 binding/version/permissions/mindScope 的完整 page/plugin contractdecoder 严格拒绝未知字段;它不是摘要回调 contract。推荐新增独立 summary contract/type/reader,避免给现有响应加可选字段形成第二来源。
- apps/onetalk-contract/src/model.ts:174-178,205-217 的 OneTalkMindScope 强制 mindUserId/workspaceId/channelAccountId。摘要成功响应不应伪造 mindUserId、binding 或 authorization version;“摘要只读”也不是现有 OneTalkOperation 枚举的一种新通用操作。
- apps/server/src/mind-authorization.ts:12-20,49-90,92-131,150-231 当前 client 只有 binding/session 两个 callback path,并且 decisionFromResponse 强制解码完整 Mind response。摘要 adapter 应有独立的 URL、请求/响应 decoder 和错误映射;不得将摘要 token 注入 WebSocket 或完整 reader。
- apps/server/src/app.ts:32-110 将同一个完整 authorization reader 注入 HTTP 和 WebSocket。摘要 reader 必须作为单独依赖只注入 HTTP read routesapps/server/src/websocket/index.ts:33-43,123-162 的 WS auth 保持完整 Cookie/session reader。
- /Users/dongruiyang/project/trade-mind/docs/specs/tm-message-center-summary-interface-contract.md:29-52,142-160,273-295 规定背景摘要请求使用 Authorization: Bearer、X-Mind-Purpose: communication_summary_read、X-Mind-Workspace-Id,只允许 messages history;固定半开区间 [from,to)、API ASC、cursor 绑定窗口和会话;没有本地 fallback。
- Mind 当前工作树的 /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-summary-authorization-repository.ts:9-36 以 workspace/account/conversation 查询并限制两条,连接表再按 workspace、account、direct 类型校验;communication-onetalk-summary-authorization-rules.ts:38-59 要求恰好一条有效 direct route、相同 channel connection/workspace/account/conversation,并拒绝 group metadata。真实归属事实在 Mind DB,不在 Center message 表。
- Mind 当前工作树的 communication-onetalk-summary-authorization-types.ts:1-35 已定义独立 OneTalkSummaryAuthorizationScope={workspaceId,channelAccountId,conversationId} 和 permissions:[read],没有 user/binding 字段;communication-onetalk-summary-authorization-service.ts:12-26 返回该独立 scope。这是 Center 应依赖的形状,但当前改动尚未确认合并/发布。
### 精确 wire 草案(实现前必须固定)
Center 收到的摘要历史请求应保持现有 URL:
~~~text
GET /api/bright/onetalk/accounts/{channelAccountId}/conversations/{conversationId}/messages
?limit=100&fromSentAtMs={from}&toSentAtMs={to}[&cursor={opaque}]
Authorization: Bearer <summary-read-token>
X-Mind-Purpose: communication_summary_read
X-Mind-Workspace-Id: <workspaceId>
Accept: application/json
~~~
该请求不发送 Cookie。X-Mind-Workspace-Id 是候选请求约束,不能被 Center 当作事实来源。
Center 摘要 adapter 调用同一 MIND_AUTH_BASE_URL 下的 Mind 回调:
~~~text
POST /internal/bright/onetalk/authorize-summary
Authorization: Bearer <summary-read-token>
Content-Type: application/json
{
"purpose": "communication_summary_read",
"workspaceId": "<workspaceId>",
"channelAccountId": "<channelAccountId>",
"conversationId": "<conversationId>"
}
~~~
当前 Mind 工作树的 route/Users/dongruiyang/project/trade-mind/apps/web/src/app/internal/bright/onetalk/authorize-summary/route.ts:19-54)确认了该 path、严格 JSON 解析和 Bearer 读取;成功形状为:
~~~json
{
"purpose": "communication_summary_read",
"scope": {
"workspaceId": "...",
"channelAccountId": "...",
"conversationId": "..."
},
"permissions": ["read"]
}
~~~
Center 必须严格校验 status、JSON 结构、purpose、唯一 read permission,以及 callback scope 与 URL account/conversation、请求 workspace 逐项相等。成功结果只能进入摘要历史读服务;不能转换为 OneTalkMindScope。callback 400/401/403/5xx、超时、重定向、非 JSON、未知字段或 scope 不一致都必须失败可见。建议映射为:401 auth_required403 scope_mismatch 或 authorization_rejectedtransport/5xx/解码错误 authorization_unavailable400 需在共享 contract 中明确是 invalid_request 还是 scope rejection,因为当前 Center stable error enum 没有 invalid_request。
### 配置、启动、发布
- apps/server/src/config.ts:8-22,84-148 是唯一 env 解析边界;业务代码不能直接读 process.env。摘要配置至少应在此处校验 token 非空且满足已约定最小长度,并注入 callback base URL/timeout;不要在 app/service 中临时读取 token 或静默默认。
- apps/server/src/app.ts:45-110 的 authorizationFor、HTTP route 安装和 WS 安装是关键注入点。摘要 client 要作为独立 dependency 传给 installOneTalkReadRoutes,不能覆盖完整 authorizationapps/server/src/entry.ts:14-36 和 apps/server/src/dev-entry.ts:51-58 只负责传递安全诊断 sink。
- .github/workflows/release_ci.yml:47-58,86-107,226-239,270-295,369-383 的质量校验、deploy env、required-variable 检查和 runtime env 写入链当前没有摘要 token。发布改动需在 secret env、required check、build_env 和 write_env_value 全链路传递,并保持现有 chmod 600/rollback env 文件流程;不能 echo secret。
- .env.example:1-28 当前没有摘要 token,应由实现者按 required env 规则补充占位说明(不得写真实 token)。没有数据库迁移迹象;摘要读取沿用现有 message schema。
### 当前 Mind wire 与验证缺口
- /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-center-summary-client.ts:26-103 已发送准确的 Authorization、X-Mind-Purpose、X-Mind-Workspace-Id、Accept,且每次都带 fromSentAtMs/toSentAtMsreadOneTalkCenterSummaryHistoryPage 固定 limit 100。它将网络错误、非 JSON 和 Center stable error 映射为显式可重试/不可重试错误,不发送 Cookie。
- /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-center-summary-source-service.ts:17-80 当前工作树已将首次读取的 to 固定为 before/now,遍历所有页并排序;communication-onetalk-summary-window-service.ts:24-153 对 history_incomplete 等待并记录 workspace/conversation/count。历史完整性修复在 Mind 工作树存在,但未确认已经发布。
- Mind /apps/web/src/app/internal/bright/onetalk/authorize-summary/route.ts 和对应 tests 是未提交工作树内容;当前仅发现 rules 测试,未发现 route-level HTTP 集成测试,也未确认生产部署脚本的 token 实际注入位置。Center 不能把这部分视为已经可用的外部依赖。
## Invariants and Acceptance Probes
### 结构性不变量与所有者
1. **归属不变量(Mind owns it**:允许摘要读取当且仅当 Mind 数据库为给定 workspaceId/channelAccountId/conversationId 找到恰好一条有效 direct route,并且连接、workspace、account、conversation 全部相等。Center message rows 只证明内容存在,不能证明当前租户归属;X-Mind-Workspace-Id 只能作输入候选。这个不变量由 Mind callback service/repository/rules 持有,Center 只校验 callback 输出。
2. **协议/目的不变量(Center HTTP owns it**:只有 history GET 且 purpose 精确为 communication_summary_read 才能进入摘要 adapterlist/detail/send/WS 永远走原 Cookie/full reader 或被拒绝。缺少/错误 Bearer 不得 fallback Cookie。
3. **scope contract 不变量(shared contract owns it**:摘要结果是独立的 {purpose, scope:{workspaceId,channelAccountId,conversationId}, permissions:[read]};禁止 binding、mindUserId、authorization version 的伪造或可选扩展。
4. **历史窗口不变量(read service owns it**:摘要必须有有效半开 [from,to) 时间范围;cursor 只能在相同 account/conversation/window/asOf 上向更旧消息移动;服务返回 ASC 且不因授权分支改变分页。
5. **秘密和可观测性不变量(config/diagnostic boundary owns it**token 只在 config/adapter header 中流动;日志、错误 body、响应不得出现 token、Cookie、完整 URL、连接串、消息正文。失败必须输出稳定 error code 和安全诊断字段,不能吞错或伪造成功。
### 可执行测试矩阵
- **Adapter/contract 单测(Center**fake fetch 验证 callback URL、POST body 字段精确、Bearer/Content-Type header、无 Cookie;正确 response 允许;缺字段、未知字段、重复/额外 permission、错误 purpose、scope 任一字段不匹配、非 JSON、重定向、超时、5xx 均 fail-closed,并映射稳定 code。
- **HTTP 隔离单测(Center**:正确 Bearer+purpose+workspace 只可读 historymissing/invalid/wrong Bearer 得 401wrong callback workspace/account/conversation 得 403summary token 请求 list/detail/WS/send 不得调用 summary adapter;缺 purpose/错误 purpose 不得把 Bearer 当摘要凭据;Cookie 页面路径仍调用完整 reader;摘要窗口缺 from/to 仍 400。
- **分页/回归单测(Center**>100 消息、同 sentAt 不同 messageId、跨页、固定 from/to、重复 cursor/out-of-range cursor,验证无漏项、无重复、ASC 输出和 cursor 向旧页前进。此处复用现有 apps/server/test/onetalk-http.test.ts:255-340 的历史和 missing-range 入口。
- **回滚与数据库集成**:真实 PostgreSQL fixture 验证授权通过后只执行 SELECT、history_incomplete 503+Retry-After、数据库故障 503;重复执行不产生写入;跨租户/群组 route 无法读取。真实 HTTP→Mind callback→Center PostgreSQL→Mind 回读→页面展示属于跨仓验收,不能由 Center 单测证明。
- **发布配置检查**CI 中验证 token secret 从 deploy job 进入 runtime env;缺失/过短启动失败;rollback 仍恢复含 token 的旧 env;输出不能暴露 secret。质量 job 使用测试 token 或显式 fixture,不读取生产秘密。
- **静态/工程检查**:对新 adapter、createApp/loadConfig/installOneTalkReadRoutes 做 impact 后再改;改完 detect_changes;运行 Center 目标 unit tests、typecheck/build 和 60 秒 hard timeout;检查 WS、list/detail 没有引用 summary reader。
### 异步、快照、侧效应和回滚边界
- mindOriginGuard 的 cutover epoch 是请求 admission 快照;摘要远端 fetch 是第一个 await,成功后才允许 history DB awaitDB 返回或抛错后必须再次检查 epoch 再发送结果。
- Mind callback 只做授权 DB 读取,不能写 Center、创建租户绑定或修改会话;Center read repository 只读。token/URL/Cookie 不进入 diagnostic payload。
- transport timeout、callback 失败和数据库失败都应保留明确错误,不能 catch 后返回空页。部署失败时由 workflow 现有 rollback env 机制回退,不能留下半配置 token。
## Verified facts, hypotheses, and external boundaries
### Verified facts
- Center 当前没有 summary Bearer 分支;摘要 purpose 目前仍沿用完整 Cookie reader。
- Center 完整 authorization contract 强制 binding/version/mindUserId,且同一 reader 被 HTTP 与 WS 共用。
- Center history read 已有固定窗口、旧向分页和 history completeness gate;数据库没有 workspace ownership 字段可供安全推断。
- Mind 当前工作树 client 的 wire 字段、callback route 的 request/response 形状和 exact route rules 如上所列。
- 当前 Center release workflow 没有摘要 token 的完整注入链。
### Hypotheses requiring implementation decision
- 摘要 token 是否必须在 Center config 侧做同样的最小长度校验,还是仅由 Mind callback 校验;建议两侧都 fail-fast,但 authority 仍是 callback,不能接受本地 token 命中后绕过 callback。
- callback 与消息请求是否共用同一 token;当前 Mind route/client 是共用,但应写入共享 contract/secret naming,避免后续误分离。
- BrightHistoryResponse.scope 是否返回 {workspaceId,channelAccountId} 还是连同 conversationId;外部 contract 允许省略 browser-only mindUserIdCenter 当前 TS response 要求完整 OneTalkMindScope,这是必须在 shared type 中定稿的结构边界。
- callback 400 在 Center stable error 中映射到 400 invalid_request 还是 403 scope_mismatch;两端必须一致,不能按 HTTP status 猜测归属。
- CORS 当前 apps/server/src/http/onetalk.ts:294-311 只允许 content-type,x-mind-purpose。当前摘要 client 是 server-to-server,不需要浏览器 preflight;若未来浏览器直接携带 Authorization/workspace header,必须显式扩展契约和测试,不能无意扩大 page route。
### External / unverified boundaries
- Mind summary route、rules、client 和 deploy changes 在 /Users/dongruiyang/project/trade-mind 当前是未提交工作树;未证明已经合并、部署或可从 Center 访问。
- 未发现 Mind route-level HTTP 集成测试,也未确认 production bootstrap/secret manager 在何处实际注入 TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN;仅见 .env.example 和 workflow 的条件校验。
- 本研究未连接真实 PostgreSQL、未执行跨仓 HTTP、未验证页面展示/事务回滚/生产发布;这些必须由实现/验收阶段完成。
- 父代理报告 GitNexus index 已刷新,但本研究没有重新打开图数据库;此前对授权符号的 direct impact 计数偏低,动态 Fastify route、env workflow 和跨仓调用不一定被完整捕获,不能据此降低权限风险。
## Files found
- apps/server/src/http/onetalk.ts — Center Bright v3 的 list/detail/history route、Cookie 授权、purpose、错误和 cutover。
- apps/server/src/mind-authorization.ts — 当前 Mind binding/session callback client 与完整 scope decoder。
- apps/onetalk-contract/src/authorization.ts — 完整授权响应/reader/strict decoder。
- apps/onetalk-contract/src/model.ts — OneTalkMindScope 和操作模型。
- apps/server/src/onetalk/read-model.ts — Center read input/result 与 full scope 类型边界。
- apps/server/src/onetalk/read-service.ts — 固定窗口、cursor、历史完整性和分页编排。
- apps/server/src/onetalk/read-repository.ts — Center conversation/message DB read filters and DESC paging。
- apps/server/src/onetalk/read-cursor.ts — v2 向旧消息翻页 cursor 编解码。
- apps/server/src/config.ts — required env 与 Mind auth config 解析边界。
- apps/server/src/app.ts — HTTP/WS dependency wiring 与 full authorization reader。
- apps/server/src/entry.ts、apps/server/src/dev-entry.ts — production/dev startup 与 safe diagnostics。
- apps/server/src/websocket/index.ts — WebSocket full page/session authorization wiring。
- apps/server/test/onetalk-http.test.ts — history range、auth failure、origin/cutover 和错误映射测试入口。
- apps/server/test/mind-authorization.test.ts — 现有 binding/session adapter tests。
- .github/workflows/release_ci.yml — quality/deploy env、required checks、runtime env and rollback path。
- .env.example — Center local env documentation。
- /Users/dongruiyang/project/trade-mind/docs/specs/tm-message-center-summary-interface-contract.md — 跨仓摘要 messages/callback authorization contract。
- /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-center-summary-client.ts — Mind→Center real request headers/query/error handling。
- /Users/dongruiyang/project/trade-mind/apps/web/src/app/internal/bright/onetalk/authorize-summary/route.ts — 当前 Mind summary callback route(未提交)。
- /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-summary-authorization-{types,rules,repository,service}.ts — 当前 Mind exact route ownership contract(未提交)。
- /Users/dongruiyang/project/trade-mind/apps/web/src/features/communication/communication-onetalk-center-summary-source-service.ts — Mind full-page source read and deterministic sort(未提交修复)。
- /Users/dongruiyang/project/trade-mind/.github/workflows/deploy-production.yml — Mind-side conditional token validation(实际 secret provisioning 未定位)。
## Caveats / Not Found
- 本轮只写本 evidence 文件,没有修改 Center 应用代码、配置、spec 或 Git index。
- Center 现有 response/read-service model 使用完整 OneTalkMindScope;若实现独立摘要 scope,需要明确 shared contract/export 和 read-service 输入是否 union,这是结构性改动,不能只在 route 做类型断言。
- “Token 校验通过”不等于“租户归属通过”。任何仅比较 Center 本地 token、信任 X-Mind-Workspace-Id、或从 Center message rows 推断 workspace 的实现都不满足安全契约。
- 未找到已存在的 Center authorize-summary callback client/route 或 summary-specific deployment secret;实现者应按上面的独立 adapter 建立,而不是扩展当前完整 Mind response。
## 实施核查修正
实现者实际文件检查确认环境示例唯一所有者为根 `.env.example`dev-entry 从根加载 `.env*`;研究初稿错误记录为 server 子目录,已修正。上述行号为研究时快照,不代表最终 diff。
@@ -0,0 +1,26 @@
{
"id": "onetalk-summary-read-authorization",
"name": "onetalk-summary-read-authorization",
"title": "Center 后台纪要只读授权与发布配置",
"description": "实现 Center 纪要专用服务授权、Mind 精确归属回调、部署配置与脱敏日志,保持 Cookie 和分页契约不变。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-07",
"completedAt": "2026-09-07",
"branch": "codex/onetalk-summary-read-authorization",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,37 @@
# Center summary 分支验证记录
日期:2026-09-07。分支:codex/onetalk-summary-read-authorization;基线:main 073f4e5。范围仅 Center,无数据库迁移、无 Mind 仓库修改、无生产配置或部署操作。
## 结果
独立 final-integration checker 最终 accepted,未遗留 blocking_local finding。完整服务端源码和重新编译产物各 125/125;contract 源码和产物各 28/28release tests 4/4。最终 token grammar 定向复验源码/产物各 16/16。
## 已验证边界
- 固定 Mind callback 的真实 loopback HTTP 请求、精确 purpose/scope/read permission、无 Cookie 转发;错误 token、混合凭据、三维 scope mismatch、严格响应形状与稳定错误码。
- callback 真实 timeout、redirect、非 JSON 和 500 均失败关闭;消息路由不回落 Cookie,敏感历史响应 no-store。
- 原 Cookie/CORS、WS/send、cutover 回归;后台 Bearer 无 Cookie 不能成为 Mind WS 身份。
- 101 条消息多页读取、页内升序且向更旧翻页、固定窗口和 cursor 绑定、history_incomplete 等待;沿用原 read service/repository,无 SQL 改动。
- 编译后的 production entry 通过真实 HTTP 触发缺配置 503 authorization_unavailable,默认 stderr 诊断确实输出且不含 Token;回调与读取 fixture 诊断无凭据/scope/正文。
- Release workflow YAML 解析、现有 release tests 与 secret -> stdin -> 受限临时文件 -> server.env 链静态检查。实际 SSH/远端发布未执行。
## 执行方式
- Node 22.22.2(项目 .nvmrc);所有后端测试通过 Python subprocess.run(timeout=60) 外套硬超时。
- 服务端:node --experimental-strip-types --test 加 apps/server/test 下全部非 integration 测试;重新编译后 node --test 加 dist/test 对应全量文件。
- Contract:源码与编译产物的完整测试;release 使用仓库 scripts/release.test.mjs。
- 根 pnpm typecheck、pnpm build、pnpm format:check 通过;末次生产 helper 修改后再次 server typecheck/build,并重新执行源码/产物全量回归。最终根 format:check 和 git diff --check 通过。
- 本机 pnpm 默认检测到 enableGlobalVirtualStore 设置漂移并尝试重装,因非交互模式退出。本轮根脚本使用 pnpm_config_verify_deps_before_run=warn,保留显式提示和既有依赖,不改配置、不重装依赖。首次主代理全量复跑在沙箱下出现 listen EPERM;允许 loopback 的运行环境中独立检查者和实现者全量测试通过,不将权限失败算作代码通过。
- GitNexus 已刷新到新增模块(4247 nodes / 9677 edges)。初始单符号 impact 偏低,装配人工核对按 critical 处理;完整变更分析 HIGH 已报告用户。提交前再次 detect_changes 核查范围。
## 独立检查修复
CENTER-SUMMARY-TOKEN-GRAMMAR-001:配置原先允许内空格/逗号,运行 Bearer 不接受。第 1 轮统一 ASCII token68 + 最小 32 字符,复验发现模块内仍重复字符规则;第 2 轮改为 Bearer 仅提取 value,唯一 isSummaryReadToken 校验。最终 accepted。
测试质量修复:原 scope mismatch 测试带额外 purpose 字段,实际只触发 decoder 失败;现按合法 shape 分别替换 workspace、账号和会话,真正验证精确范围比较。额外覆盖实际 HTTP 失败和后台 Token WS 隔离。
## 外部验收与发布顺序
TEST_DATABASE_URL 未设置,且本机无 Docker/Podman/PostgreSQL 工具,因此未运行真实 PostgreSQL 集成。未执行真实 Mind 归属数据库校验、企业纪要生成、PG 回读、页面展示或生产 SSH 发布,不能把本地成功当作企业恢复完成。
先发布并启用 Mind authorize-summary 与目标 workspace rollout 范围,再由双方注入相同合法 TRADE_MESSAGE_CENTER_SUMMARY_READ_TOKEN,最后发布 Center。企业恢复和全局调度由 Mind 任务负责;回滚应用和 env 成对进行。接口与环境要求见 .trellis/spec/server/backend/summary-authorization.md。
+1
View File
@@ -4,3 +4,4 @@ export * from "./authorization.ts";
export * from "./content.ts";
export * from "./decoder.ts";
export * from "./model.ts";
export * from "./summary-authorization.ts";
@@ -0,0 +1,90 @@
// 定义纪要历史读取的专用授权契约
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"],
},
};
};
@@ -0,0 +1,42 @@
// 验证纪要读取授权协议的严格解码边界
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,6 +28,12 @@ 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;
@@ -39,9 +45,26 @@ 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,
@@ -66,6 +89,11 @@ 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 ??
@@ -102,6 +130,8 @@ export const createApp = (
authorization,
readService,
registry: oneTalkRegistry,
summaryAuthorization,
onSummaryAuthorizationDiagnostic: dependencies.onSummaryAuthorizationDiagnostic,
mindPageOrigin,
cutoverPolicy,
});
+18
View File
@@ -4,6 +4,7 @@ import {
validateChromeExtensionDownloadConfig,
type ChromeExtensionDownloadConfig,
} from "./oss/chrome-extension-download.ts";
import { isSummaryReadToken } from "./summary-credential.ts";
export type ServerConfig = {
host: string;
@@ -12,6 +13,7 @@ export type ServerConfig = {
environment: ServerEnvironment;
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
mindAuthorization?: MindAuthorizationConfig;
summaryReadAuthorization?: SummaryReadAuthorizationConfig;
};
export type MindAuthorizationConfig = {
@@ -21,6 +23,10 @@ export type MindAuthorizationConfig = {
timeoutMs: number;
};
export type SummaryReadAuthorizationConfig = {
token: string;
};
export const readServerEnvironment = (): NodeJS.ProcessEnv => process.env;
export type ServerEnvironment = "development" | "non_development";
@@ -113,6 +119,17 @@ 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 => {
@@ -145,5 +162,6 @@ export const loadConfig = (environment: Record<string, string | undefined>): Ser
environment,
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
),
summaryReadAuthorization: parseSummaryReadAuthorization(environment),
};
};
+5
View File
@@ -6,6 +6,7 @@ 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";
const entryPath = process.argv[1];
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
@@ -14,11 +15,15 @@ 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, {
onOneTalkDiagnostic,
onMindAuthorizationDiagnostic,
onSummaryAuthorizationDiagnostic,
});
const shutdown = async (): Promise<void> => {
+202 -13
View File
@@ -6,11 +6,13 @@ 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 {
@@ -21,8 +23,13 @@ import {
} 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 = "communication_summary_read";
const SUMMARY_PURPOSE = ONETALK_SUMMARY_READ_PURPOSE;
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
const MINIMUM_PAGE_LIMIT = 1;
const MAXIMUM_PAGE_LIMIT = 100;
@@ -92,7 +99,7 @@ export type BrightConversationResponse = {
};
export type BrightHistoryResponse = {
scope: OneTalkMindScope;
scope: OneTalkMindScope | OneTalkSummaryReadScope;
conversationId: string;
messages: CenterMessage[];
page: {
@@ -107,6 +114,8 @@ export type BrightReadRouteOptions = {
registry?: OneTalkConnectionRegistry;
mindPageOrigin?: string;
cutoverPolicy: OneTalkCutoverPolicy;
summaryAuthorization: SummaryAuthorizationReader;
onSummaryAuthorizationDiagnostic?: SummaryAuthorizationDiagnosticsSink;
};
type AuthorizationResult =
@@ -127,6 +136,10 @@ 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 =
@@ -191,6 +204,61 @@ 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);
@@ -269,6 +337,24 @@ const handleReadFailure = (reply: FastifyReply, error: unknown): FastifyReply |
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,
});
};
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
export const installOneTalkReadRoutes = (
app: FastifyInstance,
@@ -288,6 +374,7 @@ 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);
};
@@ -314,6 +401,8 @@ export const installOneTalkReadRoutes = (
ONETALK_CONVERSATIONS_ROUTE,
{ preHandler: mindOriginGuard },
async (request, reply) => {
if (hasSummaryReadAttempt(request))
return sendError(reply, 403, "authorization_rejected");
const authorizedScope = await authorizeRequestScope(
request,
reply,
@@ -359,6 +448,8 @@ 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,
@@ -396,22 +487,43 @@ export const installOneTalkReadRoutes = (
ONETALK_HISTORY_ROUTE,
{ preHandler: mindOriginGuard },
async (request, reply) => {
const authorizedScope = await authorizeRequestScope(
request,
reply,
options.authorization,
request.params.channelAccountId,
);
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,
);
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 = summaryPurposeFor(request.headers["x-mind-purpose"]);
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 (
@@ -419,16 +531,48 @@ export const installOneTalkReadRoutes = (
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) return sendError(reply, 400, "invalid_limit");
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
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({
@@ -441,17 +585,47 @@ export const installOneTalkReadRoutes = (
purpose,
});
if (!requestIsAdmitted(request)) {
reportSummaryRead(
options,
request,
summaryReadStartedAt,
"failed",
"authorization_unavailable",
);
return sendError(reply, 503, "authorization_unavailable");
}
if (result.status === "not_found")
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,
@@ -460,9 +634,24 @@ export const installOneTalkReadRoutes = (
} satisfies BrightHistoryResponse);
} catch (error: unknown) {
if (!requestIsAdmitted(request)) {
reportSummaryRead(
options,
request,
summaryReadStartedAt,
"failed",
"authorization_unavailable",
);
return sendError(reply, 503, "authorization_unavailable");
}
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
const failure = handleReadFailure(reply, error);
reportSummaryRead(
options,
request,
summaryReadStartedAt,
"failed",
failure ? "database_unavailable" : "internal_error",
);
return failure ?? sendError(reply, 500, "internal_error");
}
},
);
+1 -1
View File
@@ -133,7 +133,7 @@ export type OneTalkConversationReadResult =
| { status: "not_found" };
export type OneTalkHistoryReadInput = {
scope: OneTalkMindScope;
scope: Pick<OneTalkMindScope, "channelAccountId">;
conversationId: string;
fromSentAtMs?: number | null;
toSentAtMs?: number | null;
+230
View File
@@ -0,0 +1,230 @@
// 调用 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
@@ -0,0 +1,18 @@
// 定义后台纪要服务凭据的唯一语法规则
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;
};
+34
View File
@@ -62,6 +62,40 @@ 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" }),
+159
View File
@@ -1,6 +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 {
@@ -18,6 +20,9 @@ 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,
@@ -305,6 +310,160 @@ 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}`,
},
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}`,
"x-mind-purpose": "communication_summary_read",
"x-mind-workspace-id": summaryWorkspaceId,
},
});
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);
} finally {
await closeApp(app);
}
});
test("rejects summary reads unless both window endpoints are present", async () => {
let calls = 0;
const app = createApp(testConfig, {
@@ -315,6 +315,61 @@ test("uses one snapshot and half-open window for history page pairs", async () =
assert.equal(harness.historyQueries.length, 1);
});
test("paginates more than one hundred history messages toward older records", async () => {
const rows = Array.from({ length: 101 }, (_, index) =>
message({
messageId: `message-${101 - index}`,
sentAtMs: 1_000 - index,
}),
);
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => {
const query = harness.historyQueries.at(-1);
if (!query?.cursor) return rows;
return rows.filter(
(row) =>
row.sentAtMs < query.cursor!.sentAtMs ||
(row.sentAtMs === query.cursor!.sentAtMs &&
row.messageId < query.cursor!.messageId),
);
},
});
const service = createOneTalkReadService(harness.repository);
const firstPage = await service.readHistory({
scope,
conversationId: "conversation-1",
fromSentAtMs: 0,
toSentAtMs: 2_000,
limit: 100,
purpose: "communication_summary_read",
});
assert.equal(firstPage.status, "accepted");
if (firstPage.status !== "accepted") return;
assert.equal(firstPage.messages.length, 100);
assert.equal(firstPage.messages[0]?.sentAtMs, 901);
assert.equal(firstPage.messages.at(-1)?.sentAtMs, 1_000);
assert.equal(firstPage.page.hasMore, true);
const secondPage = await service.readHistory({
scope,
conversationId: "conversation-1",
fromSentAtMs: 0,
toSentAtMs: 2_000,
limit: 100,
cursor: firstPage.page.nextCursor,
purpose: "communication_summary_read",
});
assert.equal(secondPage.status, "accepted");
if (secondPage.status !== "accepted") return;
assert.deepEqual(
secondPage.messages.map(({ sentAtMs }) => sentAtMs),
[900],
);
assert.deepEqual(secondPage.page, { hasMore: false, nextCursor: null });
});
test("keeps partial ordinary history readable but gates summary reads", async () => {
const harness = createRepositoryHarness({ messageRows: () => [message()] });
const service = createOneTalkReadService(harness.repository);
@@ -0,0 +1,194 @@
// 验证纪要服务凭据和 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,
});
}
});
+49
View File
@@ -18,6 +18,7 @@ import Fastify from "fastify";
import { createApp } from "../src/app.ts";
import type { DatabaseConnection } from "../src/database/index.ts";
import type { OneTalkProfileService, OneTalkService } from "../src/onetalk/index.ts";
import { createMindAuthorizationReader } from "../src/mind-authorization.ts";
import { installWebsocket } from "../src/websocket/index.ts";
import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts";
@@ -447,6 +448,54 @@ 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";
const authorization = createMindAuthorizationReader({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
fetch: async () => {
throw new Error("a missing Cookie must reject before upstream access");
},
});
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization,
oneTalkService: createServiceStub(),
});
await app.ready();
const socket = await app.injectWS("/ws/mind", {
headers: {
origin: "http://mind.localhost",
authorization: `Bearer ${summaryToken}`,
"x-mind-workspace-id": "workspace-1",
},
});
try {
const errorMessage = nextMessage(socket);
socket.send(
JSON.stringify({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "mind_page",
type: "ws.hello",
requestId: "summary-token-hello",
scope: mindScope,
payload: { requestedPermissions: ["read", "send"] },
}),
);
assert.deepEqual(await errorMessage, {
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "mind_page",
type: "ws.error",
requestId: "summary-token-hello",
scope: mindScope,
payload: { code: ONETALK_ERROR_CODES.authRequired },
});
} finally {
await closeApp(app, socket);
}
});
test("fails closed when the authorization dependency is unavailable", async () => {
const app = createApp(testConfig, {
database: createDatabaseStub(),