mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
fix(server): authorize background OneTalk summary reads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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_incomplete,Retry-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-summary;Authorization 同一配置 secret;JSON 恰为 {purpose,workspaceId,channelAccountId,conversationId},不转发 Cookie。
|
||||
- 200: {purpose:"communication_summary_read",scope:{workspaceId,channelAccountId,conversationId},permissions:["read"]}。严格字段/类型/单一 read 权限解码,返回 scope 必须逐项匹配。
|
||||
- Mind 稳定拒绝: 401 auth_required;403 scope_forbidden / summary_workspace_disabled;400 invalid_request;503 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 在异步授权和读取后的 fence;summary 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 LOW;startServer 1 direct/2 total LOW;installOneTalkReadRoutes 1 direct LOW;authorizeRequestScope 1 direct/2 total LOW。图仍漏部分装配调用,人工核对 createApp -> HTTP/WS,startServer -> 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 校验;无新增行为范围。
|
||||
+9
@@ -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 build;server 源码和构建单测均使用外部 60 秒硬超时;contract 测试;相关 release 测试。能使用隔离 PostgreSQL 则运行既有 integration,否则明确标注未验证。任何真实服务测试仅在本 integration 工作区进行。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Center 后台纪要只读授权与发布配置
|
||||
|
||||
## Goal
|
||||
修复 Mind 后台无法读取 Center 历史消息的服务授权缺口,交付独立分支内可审查、可验证的 Center 改动。
|
||||
|
||||
## Requirements
|
||||
- 专用 Bearer 仅可用于 GET messages,purpose 必须为 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_TOKEN;Center 示例和 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 对齐。
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# Research: Center summary read authorization
|
||||
|
||||
- Query: 评估 Center 纪要历史读取的专用 Bearer 授权调用链、跨仓 wire 字段、权限隔离陷阱、部署注入和验证入口。
|
||||
- Scope: mixed(Center 内部源码与规范;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 epoch;installOneTalkReadRoutes 在授权前、读服务返回后和异常路径都会重新检查 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 cursor;apps/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、to;apps/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 contract,decoder 严格拒绝未知字段;它不是摘要回调 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 routes;apps/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_required;403 scope_mismatch 或 authorization_rejected;transport/5xx/解码错误 authorization_unavailable;400 需在共享 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,不能覆盖完整 authorization;apps/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/toSentAtMs;readOneTalkCenterSummaryHistoryPage 固定 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 才能进入摘要 adapter;list/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 只可读 history;missing/invalid/wrong Bearer 得 401;wrong callback workspace/account/conversation 得 403;summary 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 await;DB 返回或抛错后必须再次检查 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 mindUserId,Center 当前 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/28;release 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。
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user