feat: sync OneTalk customer profiles

This commit is contained in:
YBF
2026-09-01 18:44:32 +08:00
parent b9e671a374
commit a1dae83d78
64 changed files with 4536 additions and 61 deletions
@@ -29,6 +29,7 @@
| [OneTalk 扩展安装实例设备身份](./onetalk/device-identity.md) | deviceId 生成、迁移、独立存储和配置生命周期 | 已验证 |
| [OneTalk Service Worker 状态与诊断](./onetalk/runtime-diagnostics.md) | 状态投影、错误脱敏和 development 构建 | 已验证 |
| [OneTalk PWA 出站发送 SOP](./onetalk/send-sop.md) | `sendUIMessages` 输入、SDK-only 发送、WebSocket 旁路事实与联调顺序 | 目标契约 |
| [OneTalk 联系人资料观察与投递](./onetalk/contact-profile-sync.md) | profile 白名单、独立 ledger、Bright frame、ACK 和账号/epoch 隔离 | 已实现并有 focused tests |
## 开始前检查
@@ -0,0 +1,125 @@
# OneTalk 联系人资料观察与投递契约
## 1. Scope / Trigger
当 OneTalk 页面需要把当前已加载的单聊基础资料经现有 page bridge、插件 Bright WebSocket 和 Mind HTTP 投递时,遵循本契约。第一阶段只观察 `window.__conversationListData__` 初始快照和 `im-conversation-list:syncData` 更新;邮箱、注册时间、买家标签、详情微应用、DOM 刷新和群聊成员不在范围内。
## 2. Signatures
```ts
type OneTalkContactProfile = {
conversationId: string;
aliId: string;
accountId: string | null;
loginId: string | null;
name: string | null;
companyName: string | null;
countryCode: string | null;
currentTimeZone: number | null;
serviceType: string | null;
observedAtMs: number;
profileFingerprint: string;
observationStatus: "confirmed" | "partial";
};
createOneTalkContactProfileObservedFrame(
context: { connectionType: "plugin"; requestId: string; scope: OneTalkPluginScope },
profiles: OneTalkContactProfile[],
): OneTalkContactProfileObservedFrame;
OneTalkBrightClient.sendContactProfiles(input: {
profiles: OneTalkContactProfile[];
requestId?: string;
}): string | null;
OneTalkContactProfileStore.putPendingProfile(
channelAccountId: string,
profile: OneTalkContactProfile,
): Promise<OneTalkContactProfileLedgerRecord>;
OneTalkContactProfileStore.markProfileUploaded(input: {
channelAccountId: string;
aliId: string;
fingerprint: string;
uploadedAt: number;
}): Promise<boolean>;
```
## 3. Contracts
### Source and identity
- MAIN world only reads the page-owned snapshot/update sources and constructs a new whitelist object; it never forwards a raw row or response.
- `channelAccountId` comes only from `currentUserAccountId` or `IcbuIM.UserUtil.currentUser.accountId`. URL `activeAccountId` is the selected counterpart and is never a fallback.
- The profile scope key is `[channelAccountId, aliId]`; `conversationId` is source context and `loginId` is display/assistance data.
- The page profile envelope contains `channelAccountId`. Service Worker accepts it only when it equals the last valid hello on the same Port and the active coordinator account.
### Wire and Mind boundary
- `contact.profile.observed` is a non-empty plugin frame with `profiles.length` in `1..100`; its complete serialized frame is at most `256 KiB`.
- `contact.profile.ack` is a plugin-direction server frame with `status: "delivered"` and a positive `profileCount`; it means only that Mind returned an HTTP response, not that Mind committed business data.
- Both profile frames use the existing plugin binding, `sync` operation and `read` permission. No second socket, credential, Cookie, Mind user/workspace or page encryption parameter is introduced.
- Mind request is `POST /internal/bright/onetalk/contact-profiles` with only `{ channelAccountId, binding, profiles }`. The adapter explicitly picks the 12 approved profile fields and does not read the response body.
### Durable lifecycle
- IndexedDB version `4` adds the independent `onetalk_contact_profiles` store; message, candidate, checkpoint and anomaly stores remain unchanged.
- A record keeps the key, last ACKed fingerprint, timestamps and at most the newest pending sanitized profile. The pending profile is replaced by a newer fingerprint.
- Observation writes pending before Bright send. An ACK marks the record only after the readwrite transaction completes and only when the current pending fingerprint still matches. Old/unknown ACKs are no-ops.
- Reconnect, Service Worker restart and a new valid page identity rebuild sends from durable pending. Same fingerprint with no pending is skipped. There is no timer-based infinite retry.
- Page/configuration callbacks use connection, page and configuration epochs; stale callbacks cannot operate a replacement engine/coordinator or route a snapshot to another account. Snapshot is fire-and-forget and cannot block message bootstrap.
### Sensitive-field boundary
`chatToken`, `aliIdEncrypt`, `accountIdEncrypt`, `loginIdEncrypt`, `kHTAccessToken`, Cookie, raw rows/responses and Mind user/workspace fields never cross MAIN→bridge, page→Port, Mind HTTP, diagnostics or IndexedDB profile payloads. `deviceId` may remain in the existing plugin WebSocket scope/connection-integrity context, but never in profile business fields, Mind HTTP body, diagnostics or profile ledger records.
## 4. Validation & Error Matrix
| Condition | Result |
| --- | --- |
| Missing `conversationId`/`aliId`, invalid fingerprint/time/status or extra profile key | reject as `invalid_message`; no send |
| Extra profile-frame key, sensitive key or wrong profile frame direction | reject as `invalid_message`; no secret echo |
| Empty or over-limit profile batch | reject as `invalid_message`; no Mind call and no ACK |
| Missing logged-in page identity or account switch before `syncData` | drop update; do not write the old account ledger |
| Page envelope account differs from hello/config account | drop update; emit only safe identity-mismatch diagnostic |
| Duplicate hello with unchanged page identity | do not start another snapshot lifecycle |
| Page/config epoch is stale | no engine/coordinator/command side effect |
| IndexedDB write/commit abort | no Bright send or local delivered state; pending remains or operation fails explicitly |
| Bright/Mind HTTP has no response, timeout or transport error | no ACK; durable pending remains |
| Mind returns any HTTP response class (`1xx``5xx`) | send ACK after current authorization/connection/policy fence |
| Authorization revoke/version/read removal, pause or connection replacement during delivery | no late ACK |
## 5. Good / Base / Bad Cases
- Good: MAIN constructs a fixed profile object, the page envelope carries its account identity, IndexedDB commits pending state, Bright sends one bounded frame, and a current post-response fence permits the ACK.
- Base: a partial profile is delivered as long as `conversationId` and `aliId` are valid; Mind owns upsert, idempotency and manual-field rules.
- Bad: serializing a conversation row, falling back to URL `activeAccountId`, putting profile data into `message.observed`, clearing pending on `fetch` start, or treating an HTTP 4xx as a database commit.
## 6. Tests Required
- Contract: exact fields, sensitive/unknown top-level keys, wrong direction/scope, empty/over-limit batches and complete-frame byte limit.
- MAIN/page bridge/runtime: snapshot/update source, group exclusion, login identity, logout/account switch, envelope mismatch, duplicate/changed hello and old-account message rejection.
- Ledger: version skip/change, durable-first, migration preserving old stores, commit-before-true, abort/error retention, restart/reconnect, stale/unknown ACK, multi-update latest-wins and chunk boundaries.
- Server: binding/read/sync authorization, canonical scope, post-delivery revoke/version/read/pause/replacement fences, all HTTP status classes, no-response/timeout/throw, explicit HTTP whitelist, no-body-read and no Bright DB writes.
- Regression: existing message observation, checkpoint/anchor, send three-state and old binding behavior remain unchanged. Direct typecheck, format check and `git diff --check` are required; real Chromium/Mind/PostgreSQL are separately identified as external verification.
## 7. Wrong vs Correct
### Wrong
```ts
await delivery({ channelAccountId, binding, profiles: rows });
ledger.delete(key);
```
### Correct
```ts
await store.putPendingProfile(channelAccountId, profile);
const requestId = bright.sendContactProfiles({ profiles: [profile] });
// On ACK, mark only the matching current fingerprint after IDB oncomplete.
await store.markProfileUploaded({ channelAccountId, aliId, fingerprint, uploadedAt });
```
### Design Decision: reuse the existing transport
Profile observations use the existing plugin Bright WebSocket and binding because Bright owns transport authorization while Mind owns CRM facts. A second plugin→Mind credential path would duplicate authorization and make account/ACK recovery inconsistent.
@@ -59,6 +59,12 @@ rejected
设备、binding、workspace 和用户字段只能作为授权/来源上下文,不能创建设备独立消息或锚点副本。
### Profile ledger (independent state machine)
联系人资料不使用消息 candidate/checkpoint/anomaly store。IndexedDB version 4 新增 onetalk_contact_profiles,业务键为 channelAccountId + aliId。记录包含 key、账号、aliId、lastUploadedFingerprint、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。
Observation 先写最新 pending,再由现有 Bright WebSocket 发送 contact.profile.observed。同 fingerprint 且无 pending 时跳过;断线、Service Worker 重启或新页面连接只从 pending 重建发送。收到 contact.profile.ack 后,必须等待 readwrite transaction oncomplete,且只确认仍匹配的 fingerprint;迟到旧 ACK 不得删除新 pending。ACK 后删除完整 pending profile,只保留最小键、fingerprint 和时间状态。
## 3. Contracts
### Durable-first observation
@@ -133,6 +139,9 @@ Service Worker 重启后必须从 IndexedDB 恢复:
| Service Worker 重启 | 从 IndexedDB checkpoint、candidate 和 mode 恢复 |
| 设备切换 | 复用账号/会话范围的消息和锚点,不创建 device 副本 |
| 页面同步 route 无精确会话 | 由页面桥规范返回明确同步 route reason |
| Profile ledger 写入/commit 失败 | 不发送 profilepending 不被伪造为已上传 |
| Profile ACK fingerprint 与当前 pending 不匹配 | no-op;保留当前 pending |
| Profile snapshot command 未返回 | 不阻塞消息 bootstrap;只产生独立 profile diagnostic |
## 5. Good / Base / Bad Cases
@@ -142,6 +151,8 @@ Service Worker 重启后必须从 IndexedDB 恢复:
- Base:完成声明已发送但尚未收到匹配锚点快照时保持 \`uploading\`。
- Bad:在 durable write 前上传、把 socket send 成功当作同步完成、或将发送 unknown 写成自动重试任务。
- Bad:按 deviceId 复制消息/锚点,或者使用内存 cursor 跳过 IndexedDB 恢复。
- Good:资料 ledger 独立于消息 stores,按 [channelAccountId, aliId] latest-wins;旧 ACK 只影响同 fingerprint。
- Bad:把 profile ACK 当成 message ACK、用 store.put 完成前返回已确认,或用无限 timer 重试未响应的 Mind HTTP。
## 6. Tests Required
@@ -154,6 +165,7 @@ Service Worker 重启后必须从 IndexedDB 恢复:
- 断线恢复会重新发现会话并恢复未确认事实和 completion 声明。
- \`delivery_unknown\` 不创建发送任务、不自动重发;迟到消息继续进入普通 observation。
- 页面同步路由、Bright ACK 和 IndexedDB 事务顺序在重启/断线下保持一致。
- Profile migration 保留旧四个 stores 并只新增 profile storecommit/abort/error、重连、旧 ACK、chunk/latest-wins 和 snapshot non-blocking 都必须有回归。
## 7. Wrong vs Correct
@@ -23,6 +23,11 @@ type OneTalkPageMessage =
batch: ObservedOneTalkMessage[];
historyProgress?: HistoryPageProgress;
}
| {
type: "onetalk.page.profile-observed";
channelAccountId: string;
profiles: OneTalkContactProfile[];
}
| {
type: "onetalk.page.command";
requestId: string;
@@ -58,6 +63,7 @@ const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
- ISOLATED Content Script 只拥有页面桥和 \`runtime.Port\`;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、命令路由、上传编排和 IndexedDB 访问。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
- 联系人资料走独立的 onetalk.page.profile-observed → profile ledger → contact.profile.observed 路径;不得并入 message.observed。
### Bidirectional flow
@@ -109,6 +115,8 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
页面身份变化时,旧页面 command correlation 必须先收敛,再用新身份替换注册;迟到的旧结果不得恢复旧请求。
Profile envelope 必须显式携带当次读取的 channelAccountId。Service Worker 只接受它与同一 Port 最近一次合法 hello 及当前配置账号完全相同的消息;logout、切账号和旧 Port 消息均 fail closed。
### Command routing
所有页面 command 都必须先按 \`channelAccountId\` 隔离:
@@ -116,6 +124,7 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
- 不得广播到多个标签页;
- 不得跨账号回退;
- 不得随机选择页面;
- onetalk.contact.snapshot 是唯一同账号页面的 account-level observation trigger;重复相同 hello 不重复触发,snapshot result 不阻塞消息 bootstrap
- 账号级同步命令只发送到唯一同账号页面;
- \`onetalk.sync\` 保持 \`channelAccountId + conversationId\` 的精确页面路由;
- \`onetalk.send\` 只要求唯一同账号页面,忽略该页面当前 selected conversationcommand 的 conversationId 交给 MAIN 后,SDK input 必须以同值 \`cid\` 指定目标,\`conversationCode\` 只作兼容字段;
@@ -140,6 +149,8 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
| 页面 Port post 失败或断开 | \`delivery_unknown/send_connection_lost\`,不重试 |
| 页面 hello 身份变化 | 清理旧 pending correlation,待定命令返回 \`delivery_unknown/send_connection_lost\` |
| Service Worker 重新实例化 | 页面注册表为空,等待页面重新连接 |
| Profile envelope 账号与 hello/config 账号不一致 | 丢弃 profile observation,不写 ledger、不发 Bright |
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle |
## 5. Good / Base / Bad Cases
@@ -16,6 +16,7 @@ controller 的单一 \`getSnapshot()\` 投影包括:
- Bright connection state
- page/anchor/bootstrap state
- current engine status。
- profile ledger/pending/snapshot diagnostic 的安全状态(不得包含 profile 值或敏感字段)。
Passive page observations 可以在认证前持久化 durable candidates 和当前完整 \`anchor.snapshot\`discovery、message upload 和 completion frame 仍需等待授权与页面事实。
@@ -49,6 +50,7 @@ logOneTalkError(
- 投影可以包含脱敏配置、Bright 状态、页面 ready、anchor snapshot、bootstrap 和 engine 状态。
- Bright authenticated 不能代替 page ready、anchor snapshot 或同步完成。
- 页面观察可以先写入 durable candidate;上传和完成声明仍受授权/锚点门控。
- Profile diagnostics 只允许 requestId、profile count、safe status/code、耗时和固定字段名集合;不记录 profile value、binding、Cookie、token、raw row/response。
### Error propagation and logging
@@ -84,6 +86,7 @@ logOneTalkError(
| production 构建 | 保持当前压缩默认,不生成新增 source map |
| 页面断开 | 清理内存 page/bootstrap 状态,保留 durable checkpoint/candidate |
| Bright 已认证但页面或 anchor 未准备 | 投影保持未完成,不伪造同步成功 |
| Profile snapshot 未返回/投递无 response | 独立记为 snapshot/delivery failure;不改消息 checkpoint、candidate、anchor 或 send state |
## 5. Good / Base / Bad Cases
@@ -17,6 +17,7 @@ OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:
| --- | --- |
| [OneTalk 页面桥、Port 与命令路由](./page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和 command 路由 |
| [OneTalk 耐久同步与连接生命周期](./durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint、重启恢复和连接生命周期 |
| [OneTalk 联系人资料观察与投递](./contact-profile-sync.md) | profile 白名单、独立 ledger、profile frame、ACK 和账号/epoch 隔离 |
| [OneTalk 扩展安装实例设备身份](./device-identity.md) | deviceId 生成、迁移、独立存储、配置清除和生命周期 |
| [OneTalk Service Worker 状态与诊断](./runtime-diagnostics.md) | getSnapshot、错误投影、敏感信息脱敏和 development 构建 |
| [OneTalk PWA 出站发送 SOP](./send-sop.md) | sendUIMessages 输入、SDK-only 发送和 WebSocket 旁路事实确认 |
@@ -34,6 +35,8 @@ OneTalk MAIN world
-> IndexedDB durable write
-> Bright WebSocket upload
-> per-message ACK
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker profile ledger → existing Bright plugin WebSocket contact.profile.observed → Mind profile HTTP → contact.profile.ack。
\`\`\`
服务端命令:
@@ -58,6 +61,7 @@ Bright WebSocket
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
- Mind 是联系人资料业务事实源;Bright 只作 profile transport/authorization boundary,不保存 profile projection。
- 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。
## 4. Cross-document invariants
@@ -70,6 +74,7 @@ Bright WebSocket
- onetalk.sync 使用精确的账号/会话页面路由。
- onetalk.send 只要求唯一同账号页面;目标会话交给 MAIN 后必须由 SDK input 的 cid 指定,conversationCode 仅为同值兼容字段,当前 selected 会话不作为发送前置条件。
- 页面身份变化、Port 断开和 Service Worker 重启必须让旧 correlation 失效;不得由迟到结果恢复旧请求。
- 相同 page identity 的重复 hello 不重复启动 snapshotprofile page/config/connection epoch 失效时不得操作替换后的 engine/coordinator。
### Durable and lifecycle boundaries
@@ -78,6 +83,7 @@ Bright WebSocket
- 增量候选在命中旧 anchor 前保持 awaiting_anchor;完成声明必须等待匹配 anchor.snapshot 证据。
- Bright 连接断开或发送结果丢失不得自动重发,不创建隐式发送任务。
- Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
- Profile 重启/重连从独立 ledger 恢复 pendingACK 只在当前 fingerprint 的 IndexedDB transaction commit 后生效。
### Send and protocol boundaries
@@ -113,6 +119,7 @@ Bright WebSocket
- 确认页面 observation durable-first,发送和同步不会使用第二套状态源。
- 确认发送与同步路由语义分离:发送放宽 selected gate 不影响同步精确路由。
- 确认所有跨层 JSON 在进入 Bright frame 前已经通过唯一 adapter/decoder。
- 确认 profile frame/envelope 的方向、账号、大小和字段白名单在各自边界 fail closed,且 profile 不进入消息状态源。
- 确认 Service Worker 重启、Port 断开、Bright 断线和迟到结果不会造成重复事实或自动重发。
- 确认构建产物、Manifest、页面入口、IIFE/ES 格式和 source map 遵循对应子规范。
- 各子规范的具体测试命令和断言点以其 Tests Required 为准。
@@ -171,3 +171,48 @@ catch (error) {
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
}
```
## Scenario: OneTalk profile delivery failure boundary
### 1. Scope / Trigger
contact.profile.observed 经共享 decoder 和 binding/read/sync authorization 后调用 Mind profile HTTP endpoint;该路径不写 Bright DB。
### 2. Signatures
profile frame → Mind POST /internal/bright/onetalk/contact-profiles
HTTP response 1xx..5xx → profile ACK delivered
network/timeout/no response → no ACKplugin pending remains
### 3. Contracts
- Empty/invalid/wrong-direction profile frame is rejected before authorization or delivery.
- The adapter explicitly picks approved profile fields and never reads the HTTP response body.
- After the delivery await, revoke, binding/version/read change, pause, canonical replacement or guard failure suppresses the ACK.
- Diagnostics expose only request ID, profile count, status class/reason and duration; no profile value or credential.
### 4. Validation & Error Matrix
| Condition | Result |
| --- | --- |
| Invalid/empty profile batch | invalid_messageno Mind call/ACK |
| No delivery dependency | safe unavailable diagnosticno ACK |
| HTTP response 1xx/2xx/3xx/4xx/5xx | transport deliveredACK only after current-state fence |
| Network/timeout/throw/no response | no ACKpending remains |
| Revoke/version/read/pause/replacement during await | no late ACK |
### 5. Good / Base / Bad Cases
- Gooddecode → authorize → explicit body pick → wait headers → reauthorize/fence → ACK。
- Basefake HTTP/WS tests prove local behaviorproduction Mind endpoint remains external。
- BadACK on request start, spread raw profile, read error body, or use the message repository as a profile store。
### 6. Tests Required
- Contract empty/direction/size testsadapter all status classes/no-response/no-body-read testslatch tests for each post-delivery fence。
- Schema grep and service spy prove no Bright DB/message/anomaly/checkpoint write。
### 7. Wrong vs Correct
WrongHTTP request acceptance is treated as Mind DB success.
Correctonly a response plus the current authorization/connection fence permits ACK.
+1
View File
@@ -21,6 +21,7 @@
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、CORS/Origin 与 fail-closed 边界 | 已建立适配器与本地 mock |
| [OneTalk 联系人资料 Mind 投递](./mind-contact-profile.md) | profile HTTP 白名单、响应 ACK、异步授权 fence 和无 Bright 持久化 | 已实现并有 focused tests |
## 开始前检查
@@ -36,6 +36,11 @@ or message content. Sink failures are observational and must not alter protocol
production structured logging implementation requires a separate task with an explicit retention
and deployment policy.
Profile delivery diagnostics follow the same boundary. A profile_delivery event may contain only
requestId, connectionType, frameType, safe result/status class (1xx through 5xx or no_response),
profileCount and durationMs. It must not contain profile values, field values, binding, Cookie,
token, encrypted IDs, raw rows or the Mind HTTP response body.
The development Mind HTTP adapter may receive an injected `MindAuthorizationDiagnosticsSink`.
Its events contain only `endpoint` (`binding`/`session`), authorization `operation`, `outcome`,
HTTP `status`, and stable authorization `code`. Each request emits `request_started` before the
@@ -7,6 +7,7 @@
- TriggerBright 需要同时授权 OneTalk 插件和 Mind 消息页,但不能连接 Mind 数据库或读取认证视图。
- ScopeMind 提供的 binding/Session 两个 HTTP 判定接口、Bright 的 OneTalkAuthorizationReader 适配、同一二级域下三级域名的 Cookie/CORS/WS Origin 传输。
- ExcludedMind 登录实现、binding 签发/接管事务、CRM 业务表、消息事实、旧 OneTalk outbox/dispatch 和页面 UI。
- Profile delivery boundaryBright 可调用 profile endpoint,但不保存客户资料、不连接 Mind DB;具体 contract 见 mind-contact-profile.md。
## 2. Signatures
@@ -19,6 +20,10 @@
Headers: Cookie: <original Mind login cookie>
Body: { channelAccountId: string }
POST /internal/bright/onetalk/contact-profiles
Body: { channelAccountId: string; binding: string; profiles: OneTalkContactProfile[] }
Response semantics: any HTTP response is transport-delivered; no response is not ACKable.
### Bright adapter
type MindOneTalkAuthorization = {
@@ -60,6 +65,8 @@
- Mind 页面位于 mind.<domain>、Bright 位于 bright.<domain> 时,页面 HTTP 必须使用 credentials: "include"Bright CORS 只允许精确 MIND_PAGE_ORIGIN 并返回 credentials,不得使用 *。
- WebSocket 必须按连接类型检查精确 Originmind_page 允许 MIND_PAGE_ORIGINplugin 允许已登记的扩展 Origin。Cookie 不进入 query、Authorization、localStorage、错误、日志或业务表。Bright 只在单次授权调用内存中转发 Cookie。
- Bright 不缓存授权结果跨越复核边界。HTTP 每次请求、WS 连接/业务 frame/heartbeat 都重新授权;binding 或授权版本变化时关闭旧连接。
- contact.profile.observed 复用 sync + read 授权;Mind profile HTTP body 只含 channelAccountId + binding + profiles[]。HTTP response body 不读、不转发、不记录;响应状态只保留安全 status class。
- Profile delivery await 返回后必须重新确认 binding、完整 Mind scope、authorizationVersion、read、canonical connection、policy epoch 和 commit guard;远程 revoke/version/read removal 也不能产生迟到 ACK。
## 8. Bright v2 operation fences
@@ -0,0 +1,87 @@
# OneTalk 联系人资料 Mind 投递契约
## 1. Scope / Trigger
当 Bright 接收经过共享 contract 解码的 `contact.profile.observed` 后,需要把清洗后的基础联系人资料投递到 Mind 时,遵循本契约。Bright 只负责 canonical binding 授权和一次 HTTP 投递,不保存客户资料,不连接 Mind DB,也不实现 Mind 的 upsert、人工字段或客户关联规则。
## 2. Signatures
```ts
type OneTalkContactProfileDeliveryInput = {
channelAccountId: string;
binding: string;
profiles: OneTalkContactProfile[];
};
type OneTalkContactProfileDeliveryResult =
| { delivered: true; httpStatusClass: "1xx" | "2xx" | "3xx" | "4xx" | "5xx" }
| { delivered: false; reason: "no_response" };
POST /internal/bright/onetalk/contact-profiles
Body: { channelAccountId: string; binding: string; profiles: OneTalkContactProfile[] }
contact.profile.observed -> contact.profile.ack { status: "delivered"; profileCount: number }
```
## 3. Contracts
- The WebSocket handler accepts only a decoded plugin frame on the canonical plugin connection with matching `channelAccountId + deviceId`, binding, `sync` authorization and `read` permission.
- The handler uses the registry canonical connection and current authorization result; client-supplied Mind user/workspace values are never authority. The delivery result is awaited before ACK.
- After HTTP response headers arrive, the handler reauthorizes and rechecks binding, complete Mind scope, authorization version, `read`, policy epoch, canonical connection and commit guard. Revocation, version change, permission removal, pause or replacement suppresses a late ACK.
- Any HTTP response status class is `delivered`; fetch rejection, timeout or no response is `no_response` and never ACKs. Response bodies are not read, cached, logged or returned.
- The adapter builds each profile with an explicit `Pick` of the shared 12 fields. Runtime extra keys cannot cross the HTTP boundary.
- `contact.profile.observed` with an empty array is rejected by the shared decoder before authorization/delivery. There is no empty-batch ACK.
- `AppDependencies.contactProfileDelivery` is the test/deployment port. Without a delivery dependency, the profile branch fails closed and does not fabricate an ACK.
- There is no profile table, column, migration, message projection, anomaly payload or Bright persistence path.
## 4. Validation & Error Matrix
| Condition | Result |
| --- | --- |
| Decode failure, wrong direction, empty/oversized batch or malformed profile | stable `invalid_message`; no authorization, Mind call or ACK |
| Scope/binding/canonical connection mismatch | stable authorization/scope rejection; no Mind call or ACK |
| Missing `sync` operation or `read` permission | authorization rejection; no Mind call or ACK |
| Delivery dependency absent | safe `profile_delivery_unavailable` diagnostic; no ACK |
| Mind response status `1xx`, `2xx`, `3xx`, `4xx` or `5xx` | delivered status class; ACK only after the post-response fence |
| Network error, AbortSignal timeout or delivery exception | `no_response`; no ACK; plugin retains pending |
| Authorization revoke/version/read removal during delivery | no late ACK; stale connection is not treated as delivered locally |
| Response body getter/read method | never invoked |
## 5. Good / Base / Bad Cases
- Good: decode → canonical/current authorization → explicit HTTP body pick → await response headers → reauthorize/fence → ACK.
- Base: a fake delivery returns each status class or no-response to prove local flow; production Mind endpoint and DB behavior remain external verification.
- Bad: `{ ...profile }` into the request body, accepting a client Mind scope, ACKing on fetch start, reading an error body, or writing profile data into the message repository.
## 6. Tests Required
- Contract tests for profile shape, exact frame keys, direction, scope, batch size and empty-batch rejection.
- Adapter tests for explicit body whitelist, injected runtime secrets, all status classes, network/timeout/throw and response-body non-access.
- WebSocket tests for canonical binding/read/sync authorization, no dependency fail-closed, no-response pending and post-delivery revoke/version/read/pause/replacement fences.
- Schema/isolated-path checks assert no profile persistence or message/anomaly/checkpoint projection; PostgreSQL integration is run only when `TEST_DATABASE_URL` exists.
- Direct server/contract typecheck, format check, focused/full tests and `git diff --check`; real Mind endpoint/TLS remains an external smoke requirement.
## 7. Wrong vs Correct
### Wrong
```ts
const response = await fetch(endpoint, { body: JSON.stringify({ ...input }) });
sendAck(input.profiles.length);
```
### Correct
```ts
const decision = await authorization.authorize({
connectionType: "plugin",
operation: "sync",
scope: frame.scope,
binding: canonical.binding,
});
if (!decision.allowed) return;
const delivery = await contactProfileDelivery(whitelistedInput);
if (!delivery.delivered) return;
await reauthorizeAndAssertCurrent(canonical, frame.scope);
sendProfileAck(frame);
```
@@ -25,6 +25,7 @@ startServer(): Promise<void>
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs。
- `createApp` composes one injected/default `OneTalkService`; `AppDependencies` may inject the service, connection registry and publisher failure sink for tests or deployment adapters。
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created`
- contact.profile.observed is a separate transport path: canonical binding/read/sync authorization → injected/default Mind profile delivery → post-response fence → contact.profile.ack; it never invokes OneTalkService or Bright profile persistence. AppDependencies.contactProfileDelivery is the replacement seam for tests.
## 4. Validation & Error Matrix
@@ -36,6 +37,8 @@ startServer(): Promise<void>
| Missing/blank `DATABASE_URL` | throw `Missing DATABASE_URL` |
| Malformed or unsupported WebSocket frame received | send the stable protocol error, then close current socket with `1003` |
| WebSocket error received | close current socket with `1011` |
| Profile HTTP response received | ACK only after current authorization/connection/policy checks; status class is diagnostic only |
| Profile HTTP no-response or delivery dependency missing | no ACK; pending remains at plugin |
## 5. Good / Base / Bad Cases
@@ -1 +1,16 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/project/architecture.md","reason":"复核跨包职责、共享 contract 所有权、Bright/Mind 事实归属和无第二事实源。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"复核 MAIN→page bridge→WS→Mind HTTP 的数据流、await fence、失败和回滚边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"复核 profile 生命周期与既有消息同步、页面身份和配置 revision 隔离。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"复核安全 envelope、方向/账号路由、snapshot command 和敏感字段不泄漏。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"复核独立 profile ledger、durable-first、ACK 竞态和重连恢复。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md","reason":"复核稳定结果、HTTP status class 和诊断脱敏。"}
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"复核 contract/page/WS decoder 覆盖和无局部 unsafe cast。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"执行并核对扩展质量门禁及测试范围。"}
{"file":".trellis/spec/server/backend/index.md","reason":"复核服务端协议、依赖注入和回归测试边界。"}
{"file":".trellis/spec/server/backend/mind-authorization.md","reason":"复核 binding/read/sync 授权、canonical connection 和异步 stale ACK guard。"}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"复核 invalid/auth/no-response/HTTP-response 的稳定错误语义。"}
{"file":".trellis/spec/server/backend/service-foundation.md","reason":"复核 handler/app dependency 组合和无 Bright DB 写入。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"复核服务端测试、构建和数据库检查结果。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-contract-page.md","reason":"独立复核 contract/page 安全、方向、身份和异步 epoch findings。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-ledger-runtime.md","reason":"独立复核 ledger commit/recovery、账号隔离、snapshot 生命周期和消息隔离。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-server-validation.md","reason":"独立复核 server authorization、HTTP response ACK、敏感字段与异步 stale ACK。"}
@@ -0,0 +1,33 @@
# Finding ledger
| ID | Invariant | Severity | Locus | Status | Owner | Evidence |
| --- | --- | --- | --- | --- | --- | --- |
| F-001 | 首次有效页面 hello 后必须通过精确 account-level `onetalk.contact.snapshot` 请求 MAIN 当前已加载快照。 | blocking | blocking_local | fixed | trellis-implement | Repair 已让重复 hello 只触发一次 snapshot,真实 identity change 仍触发新 callbackextension targeted tests 36/36 通过。 |
| F-002 | 未 ACK profile 在 Bright 重连或页面重新有效连接时必须可恢复发送;内存 requestId 不能阻挡 ledger pending。 | blocking | blocking_local | fixed | trellis-implement | P2 已验证 offline、page disconnect、authenticated recovery;只清内存 correlationdurable pending 可重建发送。 |
| F-003 | profile ACK 只有在 IndexedDB readwrite transaction 成功提交后才能返回本地已确认。 | blocking | blocking_local | fixed | trellis-implement | P2 修复 fake IndexedDB oldVersion 0/旧 stores/cursor fixture4 个 storage tests 证明 completion 前不返回 trueabort/error 后 pending 保留。 |
| F-004 | 任务验收要求最终 server tests/typecheck 可执行,新增 test 必须通过严格 TypeScript。 | blocking | blocking_local | closed | trellis-implement | 当前直接 `node_modules/.bin/tsc --noEmit -p` 对 contract、extension、server 均 exit 0server focused tests 5/5 通过。pnpm wrapper 的 registry/non-TTY 失败是环境边界。 |
| F-005 | AC8 要求根 PRD、OneTalk/server 相关规范记录已实现边界并消除冲突。 | fixed | blocking_local | main-agent | 已同步根 `prd.md` 的 R25b/R25c/R38/R38a/AC43/AC46;历史探查文档已标明详情刷新为未来范围;新增并索引 Chrome profile contract、server profile delivery contract,并补充 page bridge、runtime-sync、durable-sync、diagnostics、Mind authorization、error/logging/service-foundation 边界。 |
| F-006 | 同一联系人更新在已有 flush 并发期间不能留下未发送的最新 pending。 | blocking | blocking_local | fixed | trellis-implement | P2 验证多次更新、101 条跨 chunk 更新、latest-wins、stale/unknown ACK;最终 pending 会继续发送最新 fingerprint。 |
| F-007 | 共享 WS decoder 必须拒绝 profile frame 顶层未知键和敏感字段。 | blocking | blocking_local | fixed | trellis-implement | P1 已为 profile frame 增加完整顶层 exact-key 检查,并覆盖 chatToken、加密 ID、kHTAccessToken、rawRow 的 profile/顶层负向测试;49/49 focused tests 通过。 |
| F-008 | 页面 identity/configuration 异步回调必须绑定 connection/config epoch,旧 callback 不得操作新 session。 | blocking | blocking_local | fixed | trellis-implement | P2 为 configuration/page/connection 建立 epoch token,并以 delayed callback、配置替换、账号切换和 Port replacement 回归验证旧 callback 不操作新对象。 |
| F-009 | `syncData` 更新必须在 MAIN 身份失效或切换时 fail closed,不得把新账号资料写入旧账号 ledger。 | blocking | blocking_local | fixed | trellis-implement | P1 让 syncData update 每次读取真实登录账号,并让 page profile envelope 显式携带 accountSW 断言 envelope account 等于 hello Port account,不匹配时不持久化/处理;负向测试通过。 |
| F-010 | profile snapshot 是独立 observation trigger,不能无限阻塞 Bright/message configure/reconnect。 | blocking | blocking_local | fixed | trellis-implement | P2 将 snapshot 改为 fire-and-forget,配置/连接/消息 bootstrap 不等待 page command result;失败只进入独立 profile diagnostic,并有回归覆盖。 |
| F-011 | shared decoder 必须对 profile observed/ack 强制正确 connection direction。 | blocking | blocking_local | fixed | trellis-implement | P1 将 profile observed/ack 纳入 plugin direction guard,并覆盖 mind_page 错误 connection typecontract tests 通过。 |
| F-012 | Mind delivery await 期间远程授权撤销/版本变化不得发送迟到 profile ACK。 | blocking | blocking_local | fixed | trellis-implement | P3 在 delivery 返回后重新校验授权、版本、binding、scope、read、policy、canonical connection 和 commit guardrevoke/version/read-removal/pause/replacement latch tests 通过。 |
| F-013 | Mind HTTP adapter 必须在 runtime boundary 显式 pick profile 白名单,不得 spread 外部对象。 | blocking | blocking_local | fixed | trellis-implement | P3 将 adapter body 改为显式 profile 字段 pickruntime 注入 chatToken、加密 ID、raw row、Cookie、device/Mind 字段均未进入 bodyResponse.body getter 未读取。 |
| SRV-005 | 空 profile observed batch 必须在 contract/handler 边界 fail closed,不能调用 Mind 后发送 decoder 不接受的 `profileCount: 0` ACK。 | fixed | blocking_local | trellis-implement | repair 已在 shared decoder 拒绝空 observed batch,保留 server faithful regressioncontract/server tests 35/35 通过,空 batch 不再调用 Mind 或发送 ACK。 |
| CP-001 | 完整 profile wire frame 超限必须 fail closed。 | fixed | blocking_local | trellis-implement | contract/page checker 复核完整序列化 frame size regression 已通过;contract tests 20/20。 |
| CP-002 | 同一有效页面/账号的重复 hello 不得重复触发 profile snapshot。 | fixed | blocking_local | trellis-implement | Repair 让 identity registration 返回是否变化,仅首次/真实变化调用 onPageIdentity;两个 checker regression 复跑通过。 |
| CP-003 | 配置切换账号后,旧页面 identity 的消息不得进入新账号 engine。 | fixed | blocking_local | trellis-implement | Repair 在 page host message persistence 前校验当前配置账号;旧 A observation 不进入 B engine,正常同账号消息路径通过。 |
| EXT-001 | 同一页面、同一身份的重复 hello 不应重复启动 profile snapshot lifecycle。 | closed | blocking_local | main-agent | 与 CP-002 为同一 invariant 的第二 checker 发现,已去重保留 CP-002 作为修复 owner;第二 checker 的失败复现和报告证据已写入 check-ledger-runtime.md。 |
| EXT-002 | 页面账号必须与当前 active engine/config account 匹配后才能进入消息 observation。 | closed | blocking_local | main-agent | 与 CP-003 为同一 invariant 的第二 checker 发现,已去重保留 CP-003 作为修复 owner;第二 checker 的失败复现和报告证据已写入 check-ledger-runtime.md。 |
| DOC-001 | profile spec 对 deviceId 的允许边界必须与既有 plugin WS scope 合同一致。 | fixed | blocking_local | main-agent | 文档 checker 发现矛盾后已修订:deviceId 仅可保留在既有 plugin WS scope/连接完整性上下文,不进入 profile 业务字段、Mind HTTP、诊断或 profile ledger;文档最终复核无新 finding。 |
## Validation notes
- Independent review barrier passed after repair: contract/page, ledger/runtime and server delivery revalidation reports contain no open local code finding; F-005 documentation/spec synchronization is now fixed.
- Direct contract/Chrome extension/server TypeScript checks: passed.
- Contract/page/runtime revalidation: 57/57 passed; extension lifecycle revalidation: 36/36 passed; server profile revalidation: 15/15, existing server WebSocket/auth regression 39/39, full server suite 85 passed + 1 skipped for missing `TEST_DATABASE_URL`.
- Profile storage tests: 4/4 passed after the fake IndexedDB migration fixture modeled old stores/cursors and commit/abort/error fences; coordinator recovery/latest-wins tests passed (5/5 in revalidation scope).
- Scoped Oxfmt and `git diff --check`: passed after each implementation/review repair; initial format failures were resolved by the responsible implementer.
- Final root commands with local-cache mode (`PNPM_CONFIG_OFFLINE=true pnpm run format:check/typecheck/test/build`) passed; the test run used approved loopback capability because the sandbox-only run cannot bind `127.0.0.1`. The earlier non-offline pnpm registry/non-TTY failure is recorded as an environment attempt, not a code failure. Real Chromium, real Mind HTTP and production WebSocket/TLS remain unverified; PostgreSQL integration is skipped without `TEST_DATABASE_URL`.
@@ -1 +1,17 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/project/architecture.md","reason":"跨包职责、共享 contract 所有权和禁止第二事实源的项目级边界。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"profile 从 OneTalk MAIN 经页面桥、WebSocket 到 Mind HTTP,需按跨层数据流和副作用清单实现。"}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"扩展包基线、严格 TypeScript 和根级验证命令。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"OneTalk 跨层生命周期、页面身份、消息同步与新 profile owner 的隔离边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"MAIN/ISOLATED/Service Worker 页面桥、方向校验、精确账号路由和 account-level command 约束。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"IndexedDB durable-first、ACK、重连恢复和不得污染消息同步状态的边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-diagnostics.md","reason":"Service Worker 状态投影、稳定失败结果和敏感诊断脱敏规则。"}
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"页面/WS unknown payload 的运行时 decoder 和严格类型边界。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"扩展测试、typecheck、build、format 和 smoke 验证门禁。"}
{"file":".trellis/spec/server/backend/index.md","reason":"服务端 Fastify、OneTalk 协议和测试基线。"}
{"file":".trellis/spec/server/backend/mind-authorization.md","reason":"Mind binding 授权、fail-closed、同域 HTTP 和 policy/connection 语义。"}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"WS/HTTP 稳定错误、无 response 与已收到 response 的区别。"}
{"file":".trellis/spec/server/backend/service-foundation.md","reason":"AppDependencies、WebSocket handler 和服务端副作用边界。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"服务端测试、typecheck、build 和数据库检查边界。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-contract-page.md","reason":"复核当前 contract/page 路径、F-001F-006、新增敏感 frame 与异步 epoch 风险。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-ledger-runtime.md","reason":"复核当前 profile ledger/coordinator 生命周期、账号边界和 snapshot 非阻塞风险。"}
{"file":".trellis/tasks/08-31-onetalk-customer-profile-fetch/research/recheck-server-validation.md","reason":"复核当前 server delivery、decoder direction、远程授权 fence、HTTP 白名单和验证边界。"}
@@ -2,7 +2,7 @@
## 1. 实施前置
- 仅在用户明确批准本 task 的最终规划后运行 `task.py start`;当前仍停留在 planning
- 本 task 已进入 `in_progress`;规划产物已审核,后续按本清单完成实现、检查、规范同步和提交
- Mind 团队需要提供可联调的 profile HTTP endpoint;本 task 只实现 Bright adapter/fake contract,不假设 Mind DB schema 或业务 upsert 行为。
- 不创建 Bright 客户资料表,不接入 Mind DB 驱动,不修改 OneTalk 消息事实表和旧 outbox/dispatch。
@@ -0,0 +1,21 @@
# Contract/page revalidation report
- Checker: trellis-check (contract/page scope)
- Date: 2026-08-31
## Result
- CP-002: fixed. Repeated identical hello emits exactly 1 snapshot; a real identity change emits 2 snapshots across the two identities.
- CP-003: fixed. Old account A observation reaches active account B engine 0 times.
- F-007/F-009/F-011: fixed; sensitive/exact-key, profile account envelope and direction regressions pass.
- F-005: fixed by the final documentation/spec synchronization; root PRD, historical probe labels and indexed Chrome/server contracts now describe the implemented boundary.
- No new findings.
## Verification
- Requested contract/page/runtime tests: passed, 57/57.
- Contract and extension direct tsc: passed.
- Related Oxfmt and git diff check: passed.
- Real Chromium/OneTalk bundle and production Bright/Mind remain external_unverified.
The checker was not permitted to write non-test research files; this report was persisted by the main coordinator from the terminal result.
@@ -0,0 +1,27 @@
# Contract/page independent check report
- Checker: trellis-check (contract/page scope)
- Date: 2026-08-31
- Scope: shared OneTalk contract decoder, MAIN contact observer, page bridge, Service Worker page identity/snapshot routing and message/profile isolation.
## Findings
- F-001, F-007, F-008, F-009, F-010, F-011: fixed by current source and targeted regressions.
- F-005: open; root PRD, OneTalk/server specs and exploratory docs remain unsynchronized.
- CP-001: fixed; complete serialized profile frame size regression passes.
- CP-002: blocking_local open; duplicate hello triggers two account-level snapshots instead of one.
- CP-003: blocking_local open; old account A page message is delivered to current account B engine after configuration switch.
- EXT-CP-001: external_unverified; real Chromium/OneTalk bundle, production Bright/Mind endpoint and full build were not verified.
## Verification
- Contract targeted tests: passed, 20/20.
- Observer tests: passed, 4/4.
- Page bridge tests: passed, 11/11.
- Runtime targeted tests: failed, 15/17; CP-002 and CP-003.
- Supplementary sync-runtime tests: failed, 4/5; CP-003.
- Contract and extension direct tsc: passed.
- Scoped Oxfmt and git diff check: passed.
- Account-switch/old-envelope negative probe: passed; old profile envelope was rejected, but old message observation remains CP-003.
The checker could not write this task research file because its role permits test-file changes only; this report was persisted by the main coordinator from its terminal review result.
@@ -0,0 +1,20 @@
# Documentation/spec final review
- Reviewer: trellis-check (documentation scope)
- Date: 2026-08-31
## Result
- F-005: fixed. Root PRD R25b/R25c/R38/R38a/AC43/AC46, historical probe labels, Chrome profile/page/lifecycle specs, and server profile/auth/error/logging/service-foundation specs are synchronized.
- DOC-001: fixed. deviceId is explicitly allowed only in the existing plugin WebSocket scope/connection-integrity context and excluded from profile business fields, Mind HTTP, diagnostics and profile ledger.
- No new findings.
## Verification
- Documentation/spec index links and both new seven-section contracts: passed.
- First-phase scope (global snapshot + syncData), account identity, profile frame/ledger/ACK, Bright no-persistence, Mind response/no-response/async fence and external verification boundaries: passed.
- Historical detail-refresh/token examples are labeled as probe/future evidence and not current implementation authorization: passed.
- Formatting and git diff checks: passed.
- Real Chromium, production Mind endpoint, production WS/TLS and PostgreSQL remain external/unverified.
This report was persisted by the main coordinator from the documentation checker terminal result.
@@ -0,0 +1,22 @@
# Ledger/runtime revalidation report
- Checker: trellis-check (extension lifecycle scope)
- Date: 2026-08-31
## Result
- F-001/CP-002: fixed; duplicate hello snapshot is suppressed while true identity changes still trigger a new callback.
- CP-003: fixed; old account observation is blocked by active configuration account fence.
- F-002/F-003/F-006/F-008/F-010: fixed; recovery, commit/abort/error, latest-wins/chunk/stale ACK, epoch and non-blocking snapshot tests pass.
- F-005: fixed by the final documentation/spec synchronization; profile ledger and lifecycle boundaries are indexed in the Chrome/server specs.
- No new findings.
## Verification
- Extension lifecycle tests: passed, 36/36.
- Storage/coordinator evidence remains passed (4/4 storage, 5/5 coordinator).
- Extension/contract direct tsc and Oxfmt/git diff check: passed.
- Full suite build-hash cases had pnpm child-process/registry environment failures; classify as environment blocked, not functional pass.
- Real Chromium/Service Worker suspend-resume and production endpoints remain external_unverified.
The checker was not permitted to write non-test research files; this report was persisted by the main coordinator from the terminal result.
@@ -0,0 +1,36 @@
# Ledger/runtime independent check report
- Checker: trellis-check (extension lifecycle scope)
- Date: 2026-08-31
- Scope: profile IndexedDB ledger/coordinator/Bright lifecycle, PageRuntimeHost/configured session/sync controller/runtime composition, message/profile isolation.
## Revalidation
- F-001: regressed; first hello snapshot is reachable, but duplicate hello emits two snapshot commands (canonical ledger finding CP-002).
- F-002: fixed; offline/page disconnect/authenticated recovery preserves pending and resends.
- F-003: fixed; storage 4/4 covers migration, commit fence and abort/error pending retention.
- F-004: fixed; direct extension/contract tsc passes.
- F-005: open; root PRD/docs/spec are not synchronized.
- F-006: fixed; coordinator covers latest-wins, chunk race and stale ACK.
- F-007/F-009/F-011: fixed; contract, account and envelope regressions pass.
- F-008: fixed; delayed old callback cannot touch replacement session.
- F-010: fixed; snapshot failure/pre-configuration replay does not block configure.
- F-012/F-013: fixed by source review; server scope was not rerun here.
## New findings
- CP-002 / EXT-001: blocking_local; same page identity repeated hello triggers two snapshots instead of one. EXT-001 is deduplicated to CP-002 in the canonical finding ledger.
- CP-003 / EXT-002: blocking_local; after switching active config to account B, old account A message observation reaches the active engine. EXT-002 is deduplicated to CP-003 in the canonical finding ledger.
- EXT-003: external_unverified; real Service Worker suspend/resume, Chromium, production Bright/Mind and PostgreSQL were not verified.
## Verification
- Targeted extension lifecycle tests: 31 passed, 2 failed (CP-002, CP-003).
- Contract tests: 20/20 passed.
- Full extension tests: 153 passed, 4 failed; two Vite build-hash cases are blocked by pnpm child process/registry environment.
- Extension and contract direct tsc: passed.
- Scoped Oxfmt and git diff check: passed.
- Storage/coordinator focused evidence: storage 4/4 and coordinator 5/5 passed.
- Build, real Chromium, Mind HTTP, PostgreSQL: skipped or external boundary.
The checker could not write this report file because its role permits test-file changes only; the main coordinator persisted its terminal report here.
@@ -0,0 +1,23 @@
# Server delivery revalidation report
- Checker: trellis-check (server delivery scope)
- Date: 2026-08-31
## Result
- SRV-005: fixed; empty observed batch is rejected before Mind delivery and ACK.
- F-012: fixed; revoke, authorization version change, read removal, policy pause and canonical replacement during delivery emit no late ACK.
- F-013: fixed; explicit adapter whitelist, no runtime secret/device/Mind/raw-row leakage, and no Response.body read.
- F-005: fixed by the final documentation/spec synchronization; root PRD, profile delivery contract and server error/auth/logging references are aligned.
- No new findings.
## Verification
- Contract tests: passed, 20/20.
- Server profile tests: passed, 15/15.
- Existing server WebSocket/auth tests: passed, 39/39.
- Full server tests: 85 passed, 1 skipped because TEST_DATABASE_URL is missing.
- Server/contract direct tsc, Oxfmt, git diff check and schema isolation grep: passed.
- Real Mind endpoint, production authorization, Chromium and PostgreSQL integration remain external/unverified; PostgreSQL test skipped due missing TEST_DATABASE_URL.
The checker was not permitted to write non-test research files; this report was persisted by the main coordinator from the terminal result.
@@ -0,0 +1,63 @@
# Server profile delivery review
- Date: 2026-08-31
- Scope: `apps/server` profile adapter, WebSocket handler/diagnostics/dependency composition, authorization and async fences, Bright DB/schema/message isolation.
- Review boundary: only the two requested server test files were eligible for test changes. Production, configuration, spec/docs, task manifests and Git index were left untouched.
## Revalidation
- Finding ID: F-004
- Status: fixed
- Reproducer: `./node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json`; focused server tests.
- Evidence: direct server compiler passed; the pre-regression focused server run passed 13/13. The prior `pnpm` wrapper failure is an environment/package-manager boundary, not a direct compiler failure.
- Finding ID: F-005
- Status: open
- Reproducer: `git diff --name-only -- prd.md docs .trellis/spec`; `rg -n 'R25b|R38|AC43|不自动创建客户|补全资料' prd.md`; `rg -n 'getConversationContactDetailList|conversationServiceHttp' docs/onetalk-customer-profile-fetch.md`.
- Evidence: no root PRD/docs/spec change exists. Root `prd.md` still describes no profile completion and does not distinguish the new whitelist observation; the exploratory docs still contain the deferred detail-refresh path. This remains an AC8 documentation/spec synchronization blocker and was not changed.
- Finding ID: F-012
- Status: fixed
- Reproducer: `node --experimental-strip-types --test apps/server/test/onetalk-profile-websocket.test.ts`.
- Evidence: binding revoke, authorization-version replacement, permission removal, policy pause, and canonical plugin replacement during a held delivery all produced no late profile ACK. Current handler reauthorizes after the delivery await and checks the registry/policy fence before ACK.
- Finding ID: F-013
- Status: fixed
- Reproducer: `node --experimental-strip-types --test apps/server/test/mind-contact-profile.test.ts`; runtime profile fixture includes `chatToken`, encrypted IDs, `rawRow`, `deviceId`, Mind scope fields and `Cookie`.
- Evidence: the outgoing body contains only the approved profile fields plus `channelAccountId`, `binding`, and `profiles`; all injected sensitive values are absent. `mind-contact-profile.ts` uses an explicit `profileForMind` pick rather than spreading the runtime object.
## New findings
- Finding ID: SRV-005
- Classification: blocking_local
- Invariant and evidence: a `contact.profile.observed` frame with `profiles: []` is accepted by the shared decoder, then the handler calls Mind and emits `contact.profile.ack` with `profileCount: 0`; the shared ACK decoder rejects that ACK because it requires `profileCount > 0`. The new regression at `apps/server/test/onetalk-profile-websocket.test.ts:280-302` fails deterministically: expected `ws.error`/zero delivery calls, actual `contact.profile.ack`. Resolve at the contract/handler owner (reject empty observed batches or define a valid empty-batch ACK); production code was not changed.
## Findings (fixed)
- File: `apps/server/test/onetalk-profile-websocket.test.ts`
- Issue: SRV-005 had no regression test for the decoder/handler empty-batch mismatch.
- Fix: added a minimal faithful test that asserts no Mind delivery and no invalid ACK. It intentionally remains failing until the production/contract owner resolves the behavior.
- File: `apps/server/test/onetalk-profile-websocket.test.ts`
- Issue: no test asserted the no-delivery-dependency fail-closed path.
- Fix: added a focused test with no `mindAuthorization` and no injected delivery; it passes with no profile ACK.
## Findings (not fixed)
- F-005 remains open in `prd.md`, `docs/onetalk-customer-profile-fetch.md`, and `.trellis/spec/`; those non-test files were outside the allowed write set.
- SRV-005 remains open in production/contract code. The faithful test is retained for the implementing agent; no production source was edited.
- Real Mind endpoint existence, binding authentication behavior, production HTTP limits/SLA, real Chromium/OneTalk bundles, and PostgreSQL integration remain external or unavailable evidence. The server integration test is skipped because `TEST_DATABASE_URL` is unset; fake delivery tests are not claimed as real Mind E2E.
## Verification
- `node --experimental-strip-types --test apps/server/test/mind-contact-profile.test.ts`: pass, 4/4.
- `node --experimental-strip-types --test apps/server/test/onetalk-profile-websocket.test.ts`: fail by design after adding SRV-005, 10 passed / 1 failed; all existing authorization, HTTP, timeout, no-body-read, pause, replacement and no-dependency tests passed.
- `node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts`: pass, 19/19.
- `node --experimental-strip-types --test apps/server/test/websocket.test.ts apps/server/test/onetalk-websocket.test.ts apps/server/test/mind-authorization.test.ts`: pass, 39/39.
- `node --experimental-strip-types --test apps/server/test/*.test.ts`: fail, 84 passed / 1 failed (SRV-005) / 1 skipped (`TEST_DATABASE_URL` missing).
- `./node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json`: pass.
- `./node_modules/.bin/oxfmt --check apps/server/src/mind-contact-profile.ts apps/server/src/app.ts apps/server/src/websocket/diagnostics.ts apps/server/src/websocket/handler.ts apps/server/src/websocket/index.ts apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts`: pass.
- `git diff --check -- apps/server`: pass.
- `rg -n 'contact_profile|contactProfile|ContactProfile|contact\\.profile' apps/server/drizzle apps/server/src/database apps/server/src/onetalk`: pass, no profile persistence/schema/message-path hits.
- Runtime undefined-response probe against `createMindContactProfileDelivery`: pass, `{ delivered: false, reason: "no_response" }`.
- Runtime response-body getter probe: pass, HTTP 4xx classified as delivered and `bodyRead` remained `false`.
@@ -0,0 +1,229 @@
# Research: OneTalk 客户资料第一阶段跨层实现证据
- Query: 梳理 OneTalk 客户资料第一阶段在共享 contract/decoder、MAIN page observer/page-bridge、Service Worker Bright client/IndexedDB/sync runtime、Bright server Mind authorization/WebSocket handler 及测试/规范中的现状、可复用边界、文档冲突与最小实现文件集。
- Scope: mixed(以当前 worktree 内部代码、任务文档、规范和既有运行时探查文档为主;真实 Mind endpoint/当前浏览器状态未验证)
- Date: 2026-08-31
## Findings
### 1. 现状总览与事实归属
当前仓库没有客户资料 frame、profile 类型、profile observer、profile ledger 或 Mind profile HTTP adapter。现有 OneTalk 链路只处理消息事实和同步技术事实:
```text
OneTalk MAIN message observer
-> page bridge: onetalk.page.observed / historyProgress
-> Service Worker: message/candidate/checkpoint/anomaly
-> Bright WS: message.observed / sync.complete
-> Bright DB: message/conversation/anomaly
-> Mind page: message.created / sync.status
```
客户资料第一阶段应保持独立链路:
```text
OneTalk MAIN __conversationListData__ + EventBus syncData
-> 白名单 OneTalkContactProfile
-> page bridge profile observation
-> Service Worker profile ledger
-> Bright WS contact.profile.observed
-> Mind HTTP profile delivery
-> contact.profile.ack
```
现有 Bright schema 只有 `onetalk_message``onetalk_conversation``onetalk_message_anomaly`;消息复合键为 `channel_account_id + conversation_id + message_id`,会话/锚点复合键为 `channel_account_id + conversation_id`,没有客户资料表或列(`apps/server/src/database/schema/onetalk.ts:45-203`)。这支持“Bright 只做传输/授权、不持久化 profile”的设计,但必须防止复用消息表、candidate、checkpoint 或 anomaly。
### 2. 共享 contract/decoder:可复用和必改位置
- `apps/onetalk-contract/src/model.ts:8-50` 集中拥有 frame taxonomy、client/server direction;当前 `contact.profile.observed``contact.profile.ack` 均不存在。
- `apps/onetalk-contract/src/model.ts:67-71` 只有 `read/send` permission 和 `connect/heartbeat/read/sync/send` operation。按现有设计,profile upload 可复用 `sync` operation 与 `read` permission,不应新增 plugin/Mind scope 或 credential 概念。
- plugin scope 只有 `channelAccountId + deviceId``apps/onetalk-contract/src/model.ts:152-166`);Mind scope 才含 `mindUserId + workspaceId + channelAccountId`。profile 载荷不应自行携带 Mind user/workspace,也不应把 `deviceId` 放入 profile 业务键。
- `OneTalkBaseFrame` 的 scope、requestId 和 connectionType 是 frame 级边界(`apps/onetalk-contract/src/model.ts:239-251`)。profile range 应由 plugin frame scope 的 `channelAccountId` 表达,profile 业务键为 `channelAccountId + aliId``conversationId` 只作来源/关联上下文。
- 方向校验集中在 `apps/onetalk-contract/src/decoder.ts:210-236`payload 校验集中在 `:238-335`,最终入口为 `decodeOneTalkFrame``:345-368`)。新 frame 必须同时加入 frame list、union、direction guard、required correlation(如采用 requestId ACK)和 payload decoder;不能让各消费者局部 cast。
- 当前 decoder 对多数 payload 不禁止未知字段;profile 若要证明敏感字段不会穿过 WSprofile decoder 必须对 profile 和 frame payload 采用严格字段集合,至少拒绝 `chatToken``aliIdEncrypt``accountIdEncrypt``loginIdEncrypt``kHTAccessToken`、原始 row/response 等额外键。仅依赖 MAIN 构造白名单不足以证明恶意/错误输入不会跨边界。
- 公共出口 `apps/onetalk-contract/src/index.ts:3-5` 已 wildcard re-export,因此新增 contract 类型不需要另建功能级 re-export;唯一所有者仍应是 contract `model.ts`/`decoder.ts`
建议新增的共享形状与设计一致:`OneTalkContactProfile` 至少包含非空 `conversationId``aliId`,显式 nullable 的 `accountId/loginId/name/companyName/countryCode/currentTimeZone/serviceType`,非空 `observedAtMs/profileFingerprint``observationStatus`。必须先明确 `currentTimeZone` 的允许类型/空值规则、fingerprint 的固定字段顺序与 canonicalization、最大 profile 数量/字节数以及 `requestId` 是否按 chunk 独立 ACK。
### 3. MAIN page observer 与页面桥
已验证的页面身份边界:`readChannelAccountId` 先读 `currentUserAccountId`,再读 `IcbuIM.UserUtil.currentUser.accountId`,缺失即 `null`,不回退 URL`apps/chrome-extension/src/onetalk/main-page/page-context.ts:24-44`)。URL `activeAccountId` 明确是当前选中对话账号而非登录账号(`:19-22``:30-34`)。当前 selected conversation 从 `.contact-item-container.selected[data-cid]` 读取,并对零/一/多选分别表达(`:46-71`)。
当前 MAIN 入口只安装页面桥、消息 observer 和历史同步(`apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts:12-24`)。消息 observer 只监听页面 WebSocket,负责解析消息,不读取 `__conversationListData__` 或 EventBus`apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts:1-12`)。因此 profile 应是独立 `main-page/contact-observer/` 功能,不应扩展消息 observer 或将资料附加到 message body。
运行时探查文档记录:
- `window.__conversationListData__` 是当前已加载会话 map`__conversationListMapFullData__` 是别名;会话条目可能把基础资料放在 `contact` 或条目自身(`docs/onetalk-customer-profile-fetch.md:85-147`)。
- 页面会发布 `im-conversation-list:syncData`,回调收到 map;文档建议在 MAIN world 读取并仅构造白名单 profile,`EventBus.on` 返回 unsubscribe`docs/onetalk-customer-profile-fetch.md:192-223`)。
- 真实探查发现内部 `conversationServiceHttp.getConversationContactDetailList()` 可用,但需要加密 ID/chatToken;外层同名方法为空 Promise(`docs/onetalk-customer-profile-fetch.md:225-264`)。这条补刷新路径明确不属于第一阶段,不能把 token 带入桥或 Service Worker。
页面桥当前是版本化、同源、方向受限的通道:
- `OneTalkPageMessage` 只有 hello、observed、command、command-result`apps/chrome-extension/src/onetalk/page-bridge/model.ts:22-69`)。
- decoder 只验证 JSON/版本/结构,observed message 是可递归 JSON object`:79-115``:189-220`);增加独立 profile message 才能避免与消息 batch 语义混用。
- MAIN→ISOLATED 方向由 `isOneTalkMainToIsolatedMessage` 收敛,ISOLATED→MAIN 只允许 command`:356-391`)。ISOLATED 只做 source/origin、共享 decoder、方向检查和原样 Port 转发(`apps/chrome-extension/src/onetalk/page-bridge/isolated.ts:34-87`)。
- MAIN bridge 发布 hello、observed/historyProgress,并安装 command consumer`postMessage` 前会再 decode,失败只返回 false`apps/chrome-extension/src/onetalk/page-bridge/main.ts:43-55``:58-105``:128-176`)。因此 profile 白名单清洗应在 MAIN observer 完成,bridge 只传已构造的 profile。
设计中的 `onetalk.contact.snapshot` account-level command 与现状不一致:Service Worker runtime 目前只把 `command.action === "onetalk.sync"` 视为 account-level`apps/chrome-extension/src/onetalk/service-worker/runtime.ts:394-398`);MAIN 的历史 command handler 对非 `onetalk.send`/`onetalk.sync` 直接返回 `invalid_request``apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts:90-137`)。若保留首次 hello 后显式 snapshot command,需要增加独立 profile command handler 和 page-script dispatcher;不要让历史 handler 读取页面 profile。
### 4. Service Worker runtime、Bright client 与 profile ledger
页面 Port/runtime 的可复用边界:
- Port 注册键为 `tabId:frameId``apps/chrome-extension/src/onetalk/service-worker/runtime.ts:152-183`);页面 hello 身份变更时先 settle pending command,再替换账号/会话身份(`:229-249`)。
- observation 必须先执行 `persistPageObservation`,完成后才调用上层 handler;持久化失败不会调用 handler(`:269-294`)。这正好可作为 profile ledger 的 durable-first 边界,但当前 handler 参数只支持 `OneTalkPageObservedMessage`,需要增加 profile 专用 callback/消息类型(`:48-63``:89-101`)。
- account-level command 只允许唯一同账号页面;无页面返回 `waiting_for_page`,多页面返回 `ambiguous_page_route``:501-544`)。会话级非 send command 继续按精确 `channelAccountId + conversationId` 匹配,零匹配是 `page_identity_mismatch``:545-567`)。profile snapshot 应走账号级唯一页面,不能广播或按 conversation fallback。
- Port post 失败/断开会 resolve `delivery_unknown/send_connection_lost` 且不重试(`:197-214``:408-451`)。profile observation 是可恢复的资料上传,不应复用发送三态作为 ledger 状态;页面桥失败应让 profile 未进入 ledger,页面后续 snapshot/syncData 再观察。
Bright client 当前公开接口只有通用 `send`、消息 observed、conversation discovered、sync complete、send confirmation`apps/chrome-extension/src/onetalk/service-worker/bright-client.ts:131-157``:746-822`)。所有非 hello frame 发送前要求 socket open、authenticated、scope 相等,并再次 `decodeOneTalkFrame``:491-545`)。收到 frame 后同样解码并校验 plugin scope,之后通知 listener`:613-645`)。profile helper 应复用该单一 send guard/decoder,不另建第二条 WebSocket 或认证链。
现有 reconnect 语义:socket close 清理 heartbeat,进入 offline/unauthorized 并按配置有限度 schedule reconnect`bright-client.ts:565-600`);Service Worker sync lifecycle 在认证状态变化时增加 epoch、清理 anchor snapshot,并由 `sync-engine.ts` 恢复 pending message candidates/completions`apps/chrome-extension/src/onetalk/service-worker/sync-engine/lifecycle.ts:237-263``sync-engine.ts:224-268`)。profile 的重连恢复应读取 ledger pending,而不是依赖 `requestId` 内存 mapprofile request map 只在当前 Bright client 生命周期内存在。
IndexedDB 当前:
- 数据库名为 `trade-message-center`schema version 是 3;四个 store 是 message/checkpoint/candidate/anomaly`apps/chrome-extension/src/onetalk/service-worker/storage.ts:14-20``:243-255`)。
- `openDatabase``openSyncDatabase` 均在 `onupgradeneeded` 中确保 store,并在 oldVersion < 3 时清理 legacy `loginUserId``:277-307`)。新增 profile ledger 必须把版本从 3 单调递增到 4(或经实现者确认的下一版本),只 create 新 store,绝不删除/重建旧 store。
- 现有存储按 JSON 复合 keymessage key 是账号/会话/消息三元组,sync key 是账号/会话二元组(`:136-150`)。profile ledger key 应固定为 `JSON.stringify([channelAccountId, aliId])`,不能使用 conversation 或 device 作为唯一键。
- 当前 `OneTalkSyncStore` 只暴露 checkpoint/candidate/anomaly/message API`:87-134`)。profile ledger 需要独立方法集合,如 `getProfileLedger/listPendingProfiles/putProfileObservation/ackProfileBatch`,避免把资料状态塞入 `OneTalkSyncCandidate`
ACK/竞态可复用原则:现有消息 ACK coordinator 以 `requestId -> candidate key` 内存关联,发送前先把 candidate requestId 写入 IDBACK 时重新读取 durable record 后按状态更新(`apps/chrome-extension/src/onetalk/service-worker/sync-engine/ack-completion.ts:133-187``:243-317`)。profile 应采用同样的“requestId -> [(ledger key, fingerprint)]”内存关联,但 ACK 提升条件必须是:当前 ledger pending fingerprint 仍等于 ACK 关联 fingerprint;若 pending 已被新 fingerprint 替换,旧 ACK no-op。不能把 profile ACK 接入 `handleAcknowledgement`,因为该函数只理解 `message.ack` statuses 和 message candidate。
推荐新建独立 `ContactProfileCoordinator`(或同等明确 owner)并由配置 session 组合,而不是塞入 `AckCompletionCoordinator`/message `SyncEngine`:它拥有 profile ledger、profile request map、snapshot/incremental trigger、chunk、profile ACK 和 profile-specific diagnostics;现有 message checkpoint/anchor/候选状态保持不变。`OneTalkConfiguredSyncSession` 当前负责 client/engine 创建、dispose、generation/revision 隔离(`apps/chrome-extension/src/onetalk/service-worker/configured-sync-session.ts:100-221`),适合作为 profile coordinator 的生命周期 owner,但需要确保旧 config 的异步 profile callback 受同一 revision/generation 约束。
### 5. Bright server Mind authorization/WS handler
授权现状:
- 生产默认授权由 `createApp` 根据 normalized environment 选择 `createMindAuthorizationReader`;显式注入 reader 优先,非 development 无配置时 fail closed`apps/server/src/app.ts:23-46`)。
- Mind binding endpoint 只提交 `channelAccountId + binding`session endpoint 才转发 Cookie;响应严格解码为 binding/version/permissions/mindScope`apps/server/src/mind-authorization.ts:11-18``:25-66``:99-131`)。profile delivery 应复用已认证 plugin connection 的 binding 和 Mind scope,不重新接收/转发 OneTalk Cookie 或页面 token。
- 每次 WS frame 先检查 state connectionType/scope,再映射 operation 并重新授权(`apps/server/src/websocket/handler.ts:501-613`);`sync` operation 目前只涵盖 conversation discovered/sync complete/message observed`:289-303`)。profile frame 必须加入 plugin-only required connection、operation=`sync`、read permission 检查和 client frame taxonomy。
- handler 得到 canonical plugin connection 和 commit guard 后才进入实际业务副作用(`:670-676`)。profile 没有 Bright DB 副作用,不应调用 `OneTalkService` 或 repository;应在同一 canonical/binding/scope/policy guard 下调用可注入的 Mind delivery port。
Mind delivery 的关键 async 边界应明确:
1. decode/profile scope guard 完成;无效 profile 不调用 Mind。
2. 当前 canonical plugin connection、binding、policy epoch 可用;捕获 guard。
3. 调用 Mind `fetch`;请求 body 只含 `channelAccountId + binding + profiles[]`,不读取/缓存 response body。
4. `fetch` 得到任意 `Response`(包括 4xx/5xx)即 `delivered=true`HTTP status 仅转为安全 status class 诊断。
5. `fetch` 抛错或 timeout 是 `no_response`,不发 profile ACKpending 由插件 ledger 保留。
6. Response 到达后重新检查 connection/generation/binding/policy;如果期间 pause、revoke、connection replacement 或 socket 断开,不发送迟到 ACK。Mind 可能已经收到请求,必须把传输语义定义为 at-least-once,不能宣称 exactly-once。
当前 registry 只拥有 Mind page publish 和 send attempt`apps/server/src/websocket/registry.ts:110-143``:489-694`),没有 profile delivery API。最小方案是在 handler options/AppDependencies 注入独立 `OneTalkContactProfileDelivery`,由 `app.ts`/`websocket/index.ts` 组合;若 profile ACK 需要 registry-owned generation guard,可新增窄方法,不要复用 `publishMessageCreated``publishSyncStatus``OneTalkPublishFailure.eventType` 也只列 message/status 三类(`registry.ts:77-85`),profile 诊断应独立,不把 profile 误报成消息 publish failure。
真实 Mind profile endpoint 路径、最大 batch/字节数、timeout 和 binding 侧对 profile body 的最终校验当前都不存在于 `mind-authorization.ts``config.ts` 或 specs。任务 design 中的 `/internal/bright/onetalk/contact-profiles` 只是建议,必须由 Mind 端确认后才能实现真实 adapter;测试可以先注入 fake delivery,但不能声称真实 Mind E2E。
### 6. 现有测试与可复用夹具
可直接复用的 extension 夹具:
- `apps/chrome-extension/test/onetalk-page-bridge.test.js:50-116``FakePageWindow/FakePort`、同源 dispatch、Port disconnect、hello/observed command 构造器,适合扩展 `__conversationListData__``EventBus.on`/unsubscribe 和 profile page message。
- page bridge 已覆盖 source/origin/方向拒绝、登录账号 fallback、URL active account 不回退、hello retry、command correlation 和 Port 断开(`:122-276`)。应在同一文件或新增 profile observer test 中补敏感字段过滤、syncData 去重、snapshot command 和 pagehide unsubscribe。
- `apps/chrome-extension/test/onetalk-service-worker-runtime.test.js:31-74``FakePort/pageSender/connectPage` 可覆盖账号级 profile snapshot 唯一路由、无/多页面拒绝、身份变化/断开 pending 收敛(`:124-196``:280-460`)。
- `apps/chrome-extension/test/onetalk-bright-client.test.js:14-55``FakeSocket/accepted` 可扩展 profile observed send、profile ACK inbound、scope mismatch 和 diagnostics 脱敏;当前测试已经验证 client auth、anchor、message send、heartbeat、invalid inbound 与 close code`:57-232`)。
- `apps/chrome-extension/test/onetalk-service-worker-storage.test.js:29-88` 的 Fake IDB 只验证建库和 put,不能覆盖 profile ledger 的 `get/getAll/delete`、oldVersion upgrade、事务 abort 或 ACK 原子性。profile 实现需要扩展成可模拟 request callbacks/transactions 的 fixture,或新增不引入未声明依赖的最小 fake IDB。
- `apps/chrome-extension/test/onetalk-sync-engine.test.js:8-140` 的 in-memory sync store 和 Bright fake 适合参考 durable-first/ACK/reconnect 状态,但 profile 不应直接借用消息 candidate 方法;应新建 profile fake ledger/bright capability,明确测试 pending fingerprint 替换。
- `apps/chrome-extension/test/onetalk-configured-sync-session.test.js:99-181` 适合验证配置替换、旧 client dispose、revision 隔离和无敏感字段 snapshotprofile coordinator 必须加入相同的旧异步回调隔离测试。
可直接复用的 server 夹具:
- `apps/server/test/onetalk-websocket.test.ts:33-183` 定义 `testConfig`、plugin/mind scopes、`MockAuthorizationRecord``createDatabaseStub``createService``connectPlugin/connectMindPage/nextMessage`,适合增加 fake profile delivery 注入。
- 该测试已经验证 plugin handshake+anchor snapshot、逐条 message ACK 与 Mind publish 顺序(`:240-297`),以及 diagnostics 不含 binding/device`:299-337`)。profile 测试应证明不调用 service/repository、不写 Bright DB、Mind body 白名单和任意 HTTP status ACK。
- `apps/server/test/mind-authorization.test.ts:22-272` 已有可控 fetch、固定 endpoint、非 JSON/timeout/network failure 和严格 status/code 断言,适合抽取/复制为 profile delivery adapter 的 response/no-response testsprofile 与 auth 的语义不同,不能把任意 profile HTTP response 当成 authorization allowed。
- `apps/onetalk-contract/test/contract.test.ts:23-72` 的 plugin/mind scope、frame base、authorization record 和 `decode` helper 可复用 profile frame contract tests;现有测试也明确覆盖错误版本、错误方向、未知 frame、敏感凭证不回显(`:74-143``:430-506`)。
当前没有 extension TypeScript unit test sourceextension `test/*.test.js` 直接导入 `.ts`package script 使用 Node `--experimental-strip-types``apps/chrome-extension/package.json:6-15`)。新增测试应保持这一约定。没有真实 Chromium、真实 Mind、PostgreSQL integration 证据时,只能报告 fake/compiled contract coverage。
## Invariants and Acceptance Probes
### Invariant owner
主 invariant 是:
> 对每个 `channelAccountId + aliId`,跨边界只存在当前 OneTalk 页面生成的白名单 profile;同一 `profileFingerprint` 未获得 Bright 对应 `contact.profile.ack` 前必须可恢复,迟到旧 ACK 不能确认新 fingerprintBright/Mind 只按已认证 binding 投递,Bright 不持久化客户资料。
建议 owner
- MAIN `contact-observer/model.ts`:页面 row/contact → 白名单 profile、单聊识别、fingerprint 和页面错误。
- `apps/onetalk-contract`profile 类型、严格 decoder、frame direction/scope/operation contract。
- page bridgeprofile envelope 的 source/origin/方向和无状态转发。
- Service Worker `ContactProfileCoordinator` + 独立 profile storeledger、pending/latest replacement、chunk request map、ACK/reconnect/resend。
- Bright handler + 独立 Mind delivery adaptercanonical plugin/binding/policy guard、HTTP body 白名单、response/no-response 语义、ACK。
- Mind:最终业务 upsert/幂等/人工字段规则;本 task 只能依赖其已确认 HTTP contract。
### Sources of truth
1. OneTalk 页面 `__conversationListData__`/`im-conversation-list:syncData` 是第一阶段 profile source;不得由消息正文、URL activeAccountId、DOM 名称/列表位置或 Service Worker 反推。
2. 登录账号是 `currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId`URL `activeAccountId` 只表示 selected counterpart。
3. Profile local delivery truth 是独立 IndexedDB ledgerkey=`[channelAccountId, aliId]`requestId 仅是内存关联。
4. Bright authorization truth 是当前 canonical plugin connection + Mind authorization response/binding/version;不使用旧缓存或页面声明 Mind scope。
5. Mind DB 是客户业务事实源;Bright DB/schema 不新增 profile 事实。
### Snapshot/await/mutation/side-effect/rollback map
| 阶段 | snapshot / await | mutation / irreversible side effect | 失败与 rollback boundary |
| --- | --- | --- | --- |
| MAIN initial/update | 读取 mapEventBus callbacksnapshot command handler 无需把原始 map交出 | 只构造新的白名单 profilepostMessage 是页面外部副作用 | 缺全局/身份/aliId/cid/fingerprint 失败则跳过该项并诊断;不得发送原 rowpagehide unsubscribe |
| page bridge | decode、source/origin/方向检查;Port post 无 await durable state | window.postMessage、Port.postMessage | post 失败/断开丢弃本次 envelope;不在 bridge queue/retry |
| SW ledger | 读取 current ledger;写 pending transaction 完成后才允许 WS send | `put latest pending`;旧 pending 被新 fingerprint 替换 | IDB abort/error 不 send;保留旧已 ACK 状态;写入成功但 WS offline 时 pending 可恢复 |
| Bright WS send | auth/open/scope/decode guards`send` 后无 server ACK | wire sendrequestId map 仅内存 | send false 不清 pending;断线/重启重新从 pending 生成 requestId;不由 message ACK handler 处理 |
| Bright handler auth | decode → scope → authorization await → canonical/policy check | Mind `fetch` 是不可逆外部 side effect | invalid/auth reject 不调用 Mindfetch throw/timeout 不 ACKresponse 到达后 guard 失效不发 ACK,但不回滚 Mind 已可能收到的请求 |
| profile ACK | plugin client decode、requestId map lookup、再读 ledger current pending | fingerprint 相等才 `lastUploadedFingerprint=fp` 并删除 pending;旧 ACK no-op | ACK 写 ledger 失败不宣称本地已确认;新 pending 不被旧 ACK 覆盖 |
| config/page/socket lifecycle | configure revision、client/engine dispose、page identity/connection epoch | 关闭旧 client、清理内存 map;不清 profile ledger | 旧异步 callback 被 revision/epoch 丢弃;ledger 保留 pending,普通 SW 重启不触发全量重发 |
### Feasibility / locus of control
本仓库可以证明:contract shape/direction/scope、MAIN 白名单构造、page bridge source/origin、唯一页面路由、IDB upgrade、不把 profile 写入 Bright DB、Bright request body 白名单、任意 HTTP response 与 no-response 的 fake 行为、ACK/重连竞态和旧消息链路回归。
本仓库不能单独证明:当前生产 OneTalk 页面版本是否仍提供相同 `__conversationListData__`/EventBus 形状;真实 Mind profile endpoint 路径、认证/限流/最大 body、HTTP response 语义是否已被 Mind 接受;Mind DB upsert、人工字段保护、客户关联和最终幂等;真实 Chromium 安装后 MAIN/ISOLATED/SW build hash 与真实跨域 HTTPS/Cookie 部署。
### Executable acceptance probes
1. Contract matrix:合法/缺 `aliId`/缺 `conversationId`/空 fingerprint/nullable 字段/未知敏感字段/extra payload/错误 connectionType/错误 scope/错误 protocol version;断言只返回稳定 `invalid_message` 或 upgrade code,且错误结果无敏感值。
2. MAIN observer matrix:初始 map 两个单聊+一个群聊;同一 map 重复事件;新 aliId;同 aliId fingerprint unchanged/changedmissing global/EventBus/identity/cid/aliIdpagehide unsubscribe;断言只发送白名单 profile,群聊/非法项不发送,敏感键永不出现在 posted message。
3. Routing matrix:同账号 0/1/2 页面;不同账号页面;snapshot account commandpage identity changed/disconnect/post failure;断言只向唯一同账号页面发送,错误为明确 waiting/ambiguous/mismatchprofile 不广播。
4. IDB migration matrixoldVersion 0/1/2/3 升级后保留四个旧 store 并新增 profile store;已有 message/candidate/checkpoint/anomaly 内容不变;version 4 重复打开不重建/删除;transaction abort 不产生 WS send。
5. Ledger matrixempty→pending→ACKsame fingerprint skipchanged fingerprint replaceold pending ACK after new pendingACK unknown requestACK current mismatchextension ledger empty after reinstallSW restart pending recoveryassert key always `[channelAccountId, aliId]` and ACK removes only current matching pending content.
6. Bright matrixunauthenticated/offline/scope mismatch/invalid decoded profile/frame too large/chunk; valid profile frame; server profile ACK; reconnect; assert no second socket/auth chain and no accidental message ACK/checkpoint changes.
7. Server auth/delivery matrixinvalid profile (no Mind call); wrong account scope (no Mind call); auth unavailable/revoked/version changed (no Mind call/ACK); fake fetch 200/204/400/404/500 (all response cases ACK with status class only); fetch throw/timeout (no ACK); body contains only `channelAccountId + binding + profiles`; no Cookie/deviceId/mindUserId/workspaceId/encrypted/token/raw response.
8. Async fence matrixpause/revoke/connection replacement during auth await, Mind fetch await and just before ACK; assert no stale ACK; if Mind response already arrived, record `delivery_unknown`/no ACK without pretending rollback.
9. Regression matrixexisting message.observed ACK/publish order, message sync anchor/checkpoint, send three-state, binding takeover, page routing, and `GET /health` remain unchanged; no profile data appears in message/candidate/checkpoint/anomaly or Bright DB schema.
## Design/documentation mismatches
1. `docs/onetalk-customer-profile-fetch.md:603-618` recommends invoking the internal `conversationServiceHttp` for missing data, while task PRD explicitly excludes detail refresh/DOM and says first phase only reads global snapshot + `syncData` (`.trellis/tasks/08-31-onetalk-customer-profile-fetch/prd.md` R1 and Background). Implementation must follow the task PRD; the docs path remains future/observational evidence and must not cause token handling in this task.
2. The same docs example uses `row.contact || row`, returns nullable/partial data and treats refresh as confirmation (`docs/onetalk-customer-profile-fetch.md:694-760`), but the task design requires a strict shared `OneTalkContactProfile`, required `conversationId/aliId`, fixed fingerprint and no raw response. The example is not a production contract.
3. Root PRD still says Mind only queries existing customer data and “本项目不自动创建客户或补全资料” (`prd.md:126-127`) and acceptance AC43 repeats no profile completion (`prd.md:222-224`), while this task deliberately introduces an independent profile observation/delivery capability. The task design says R25b/R38 should be revised, but those root lines are unchanged in this worktree. A main implementation must update the root spec/PRD in the planned documentation step; otherwise AC8 is impossible.
4. Root PRD R38 says the plugin must not directly call OneTalk CRM endpoints or read/replay page tokens (`prd.md:165`), which is compatible with first-phase global snapshot but must be clarified to permit MAIN-only reading of page-owned, already-loaded whitelisted data while still prohibiting Service Worker/server token replay. The task design calls for this clarification, but the current root PRD has not made it.
5. Page bridge spec lists only hello/observed/command/result (`.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md:11-35`), and its account-level route currently describes `onetalk.sync` only (`:112-126`). The designs `onetalk.contact.snapshot` and profile observation need an explicit spec contract, not just a code branch.
6. Durable-sync spec defines message durable keys/candidates and message ACK/completion only (`.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md:11-60`, `:64-107`); it has no profile ledger/ACK semantics. Reusing its prose without a separate profile state machine would incorrectly imply per-message ACK or anchor completion.
7. Server backend index still states real Mind authentication adapter is pending (`.trellis/spec/server/backend/index.md:3-10`), and Mind authorization spec explicitly says it does not represent production integration before real Mind联调 (`.trellis/spec/server/backend/mind-authorization.md:1-9`). Profile delivery therefore cannot be called production-ready from existing auth tests.
8. `apps/server/drizzle/0000_rapid_winter_soldier.sql` contains legacy `login_user_id`/`binding_id`, later removed/renamed by `0001`/`0002`; current schema has no profile table. This is historical migration state, not a reason to add profile columns or modify the message migration. New profile functionality should have zero server migration changes.
## Recommended minimal write set and ownership
The following is the smallest coherent cross-layer set; exact filenames can be adjusted during design review, but ownership should remain separated:
1. Shared contract: `apps/onetalk-contract/src/model.ts`, `src/decoder.ts`, `test/contract.test.ts`. Add profile type/frame taxonomy/strict decoder/constructors and tests. `index.ts` wildcard export is already sufficient.
2. MAIN/page bridge: new `apps/chrome-extension/src/onetalk/main-page/contact-observer/` model/observer/command modules; `main-page/page-script-entry.ts`; `page-bridge/model.ts`; `page-bridge/main.ts` and `page-bridge/isolated.ts` only as needed for typed profile envelope/direction; `service-worker/runtime.ts` for profile callback and snapshot allowlist. Keep `message-observer` unchanged.
3. Service Worker profile lifecycle: new profile coordinator module; `service-worker/storage.ts` for schema version 4 and independent store/API; `bright-client.ts` for profile send helper; `page-runtime-host.ts`, `configured-sync-session.ts`, `sync-runtime.ts`/controller only for composition, lifecycle, revision and diagnostics. Do not modify message `AckCompletionCoordinator` semantics; do not add profile fields to message candidate/checkpoint/anomaly.
4. Server delivery: new `apps/server/src/mind-contact-profile.ts` (or an agreed Mind HTTP adapter owner), `websocket/handler.ts`, `websocket/index.ts`, `app.ts`/`AppDependencies`, and possibly `config.ts` only if the confirmed endpoint contract needs a new bounded config. Do not touch `onetalk/service.ts`, repository, schema, or migrations. Consider a shared lower-level Mind HTTP transport only if it can be introduced without duplicating authorization semantics; profile response handling must remain no-body-read.
5. Tests: extend/reuse page bridge/runtime/Bright/storage/session fixtures and `onetalk-websocket.test.ts`; add a dedicated profile coordinator/delivery test if existing files become multi-owner. Add sensitive-field negative assertions, old-ACK race, response/no-response matrix, no-DB-write assertion, and existing message regression.
6. Documentation/spec (after implementation evidence): root `prd.md` R25b/R38/AC43 boundary, chrome `page-bridge.md`/`runtime-sync.md`/`durable-sync.md`/diagnostics, server error/logging/authorization/adapter contract, and `docs/onetalk-customer-profile-fetch.md` labeling refresh/detail snippets as out-of-scope future evidence.
Implementation order should be contract → MAIN pure model/observer + bridge → independent IDB ledger/coordinator → Bright client/helper and runtime composition → server fake delivery/WS handler → tests and only then documentation/spec update. The Mind endpoint path/body/max-size/timeout decision must be confirmed before the server adapter is treated as complete.
## External references
- `docs/onetalk-customer-profile-fetch.md` — internal 2026-08-27 Chromium/runtime exploration; useful for page field names and sensitive-field boundary, not a current production guarantee.
- `.trellis/tasks/08-31-onetalk-customer-profile-fetch/prd.md`, `design.md`, `implement.md` — current task requirements/design/implementation plan.
- `.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md`, `durable-sync.md`, `runtime-sync.md`, `runtime-diagnostics.md` — current extension contracts.
- `.trellis/spec/server/backend/mind-authorization.md`, `error-handling.md`, `service-foundation.md`, `logging-guidelines.md` — current server authorization, async fence, error and diagnostics contracts.
- No external web documentation or live Mind endpoint was used/verified in this research turn.
## Caveats / Not Found
- No customer profile implementation exists in the current source tree; all findings are design/research evidence, not an implementation report.
- No real Chromium session was opened in this turn. The page global/EventBus/SDK facts are from the dated internal exploration document and may drift with OneTalk bundle changes.
- No real Mind profile endpoint, route, auth contract, max batch/bytes, timeout SLA or idempotency/upsert behavior is present or verified. Fake delivery can prove local control flow only.
- No test/build/typecheck was run because this research agent is read-only and those scripts can write/build `dist` or other workspace artifacts. Existing `dist` files are not treated as current source evidence.
- `git status --short` showed a pre-existing modification to `.trellis/tasks/08-31-onetalk-customer-profile-fetch/task.json`; it was not changed or reverted. No application/spec/manifest/Git-index change was made by this research.
@@ -0,0 +1,126 @@
# Research: contract-page recheck
- Query: 复核 OneTalk 客户资料任务的共享 contract、MAIN page observer、page bridge、首次 hello 后 profile snapshot 精确路由,以及现有 F-001F-006;重点检查 profile 字段/严格 decoder、敏感字段拒绝、账号路由和 page identity 竞态,并确认与旧消息链路隔离。
- Scope: internal
- Date: 2026-08-31
## Findings
### 1. F-001F-006 当前状态
| ID | 当前复核结论 | 证据 |
| --- | --- | --- |
| F-001 | 实现已补上,旧 finding 描述过时;但真实 host/controller 闭环没有自动化测试,且重复 hello 会再次触发 snapshot callback。 | `apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts:28-36,73-87` 已在有效身份 callback 后路由 `onetalk.contact.snapshot``runtime.ts:437-439,544-587` 将其列为 account-level 且只接受唯一同账号页面;本地 host probe 观察到 `hello -> engine/profile ready -> port.postMessage(snapshot)``runtime.ts:384-394` 对每个合法 hello 都调用 `onPageIdentity`,即使 `registerPageIdentity``:250-251` 判断身份未变化也不阻止 callback。 |
| F-002 | 当前实现已修复,focused coordinator test 通过。 | `contact-profile-coordinator.ts:267-274` 在非 authenticated 状态清理内存 request map,认证后从 durable pending flush`:282-285` 页面断开也清理 request map。`onetalk-contact-profile-coordinator.test.js:129-145` 覆盖 close/disconnect 后重发。 |
| F-003 | 当前实现已修复,focused storage test 通过。 | `storage.ts:870-896` 只在 `await completion` 后返回 `shouldMark`;事务 abort/error 会 reject,不能把未提交 ACK 当成本地确认。`onetalk-contact-profile-storage.test.js:141-187` 覆盖 commit fence 与 abort。 |
| F-004 | 当前工作树已修复,不能再沿用 finding 中的旧 server typing failure。直接 `node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json` 通过;同样的 contract 与 extension `tsc --noEmit` 也通过。 | 旧 ledger `findings.md:8,17` 仍记录失败,但当前 server test source 已可通过直接 compiler 检查。通过 `pnpm exec tsc ...` 未能得到 compiler 结果,因为本机 pnpm wrapper 先尝试 registry/install 并因无网络/非 TTY 中止;详见 Caveats。 |
| F-005 | 仍 open。AC8 所需根 PRD/spec 同步未发生。 | `git diff --name-only -- .trellis/spec prd.md docs/onetalk-customer-profile-fetch.md` 无输出;任务实现计划仍要求后置同步(`implement.md:46-51`),而 page bridge 规范仍只列旧的 hello/observed/command/result`.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md:11-35`),account-level route 仍只写 `onetalk.sync``:112-120`)。 |
| F-006 | 当前实现已修复,focused coordinator test 通过。 | `contact-profile-coordinator.ts:138-155,185-194` 增加 `followUpFlushRequested`;flush 期间的新观察会在首轮结束后再 flush。`onetalk-contact-profile-coordinator.test.js:147-163` 明确验证旧 fingerprint 后发送新 fingerprint。 |
### 2. 新增 F-007WS profile frame 顶层仍可携带敏感字段
**Severity: blocking_local;状态: open。** Profile object 自身是严格的,但完整 WS frame 不是严格 exact-key contract。`decoder.ts:152-170``PROFILE_KEYS` 拒绝 profile 内的未知字段,`decoder.ts:351-358` 也要求 `payload` 只有 `profiles`;然而 `decodeOneTalkFrame` 只在 `decoder.ts:427-438` 检查 context、方向和 payload,没有检查 frame 顶层未知键。
已执行的确定性复现:
```bash
node --experimental-strip-types --input-type=module -e 'import { decodeOneTalkFrame } from "./apps/onetalk-contract/src/decoder.ts"; const frame={protocolVersion:2,connectionType:"plugin",type:"contact.profile.observed",requestId:"r1",scope:{channelAccountId:"a",deviceId:"d"},payload:{profiles:[]},chatToken:"secret"}; console.log(JSON.stringify(decodeOneTalkFrame(frame)));'
```
实际结果为 `{"ok":true,"frame":{...,"chatToken":"secret"}}`。因此 `createOneTalkContactProfileObservedFrame()` 的安全构造路径(`model.ts:402-412`)虽不会主动加字段,wire decoder 却允许外部/错误调用者把 `chatToken`、加密 ID 或原始 row 附在 frame 顶层,违反 R2/R5 与 AC2 的“严格 decoder/敏感字段拒绝”边界。应在 profile frame 的完整 frame owner 处拒绝未知顶层键,并为 `chatToken``aliIdEncrypt``accountIdEncrypt``loginIdEncrypt``kHTAccessToken``rawRow` 分别增加顶层与 profile 内的负向测试;错误结果必须不回显敏感值。
页面桥的同类输入目前会被拒绝,这是有价值但不充分的隔离证据:`page-bridge/model.ts:234-249` 要求 profile envelope 的外层键排序严格等于 `profiles,source,type,version`。下列 probe 返回 `null`,但它不能弥补 WS contract 的缺口:
```bash
node --experimental-strip-types --input-type=module -e 'import { decodeOneTalkPageMessage } from "./apps/chrome-extension/src/onetalk/page-bridge/model.ts"; const p={conversationId:"c",aliId:"a",accountId:null,loginId:null,name:null,companyName:null,countryCode:null,currentTimeZone:null,serviceType:null,observedAtMs:1,profileFingerprint:"v1-x",observationStatus:"partial"}; console.log(JSON.stringify(decodeOneTalkPageMessage({source:"trade-message-center.onetalk.page-bridge",version:1,type:"onetalk.page.profile-observed",profiles:[p],chatToken:"secret"})));'
```
### 3. 新增 F-008page identity/configuration async callback 没有独立 epoch fence
**Severity: high_local;状态: open。** 当前路由在同步选择时是 fail-closed 的,但异步 page identity 回调没有绑定到产生它的 connection/configuration epoch
- `runtime.ts:369-394` 先确认当前 connection,再对每个合法 hello 启动 `onPageIdentity`;身份变化时旧 pending command 会在 `:250-263` 收敛,但不会取消已启动的 identity callback。
- `page-runtime-host.ts:73-87` 在多个 `await` 之间动态读取 `getActiveEngine()` / `getActiveProfileCoordinator()`,最后用 callback 捕获的旧 `channelAccountId``requestProfileSnapshot()``:98-106``replayTo()` 也使用同一形态。
- `sync-controller.ts:157-165` 在配置替换后把新 engine/profile 交给 `replayTo()``configured-sync-session.ts:154-160` 的 revision guard 只保护该 session 创建的错误/状态回调,不保护 page host 的旧 identity callback。
这不会直接把 profile 写入错误账号 ledger`page-runtime-host.ts:61-63``sync-runtime.ts:88-91` 仍按 coordinator scope 拒绝不匹配 profile。但它可能在配置替换或 page identity 改变后,让旧 callback 调用当前 coordinator 的 flush,并向旧账号页面发送 snapshot command;旧命令副作用与新 session 的生命周期不一致,且当前没有测试证明其不会发生。应加入 deterministic delayed-`handlePageReady` matrixhello(A) 后暂停,切换配置/身份到 B,再释放 A;断言旧 callback 不调用 B 的 engine/profile、不向 B 页面发送 A snapshot,且同账号重连只保留当前 epoch 的 snapshot。必要时让 PageRuntimeHost 持有并校验 connection/config epoch;只增加更多 account guard 不能替代 owner-level epoch。
### 4. Profile fields / strict decoder / MAIN whitelist
- 唯一跨边界 profile 类型是 `OneTalkContactProfile`,字段为 `conversationId``aliId``accountId``loginId``name``companyName``countryCode``currentTimeZone``serviceType``observedAtMs``profileFingerprint``observationStatus``apps/onetalk-contract/src/model.ts:238-258`)。`aliId`/`conversationId` 必须是非空字符串;nullable fields 只接受约定类型;时间必须是非负 safe integerfingerprint 非空;状态只能是 `confirmed|partial``decoder.ts:152-170`)。
- MAIN model 没有序列化 row`contact-observer/model.ts:58-96` 只读取固定字段,优先 `contact`、再以 row 顶层作字段级 fallback,重新构造新对象;`chatToken`、加密 ID、`latestMessage``msgCache` 等不可能随返回 profile 进入 sink。`contact-observer.test.js:46-63` 已验证 fixture 中敏感值不出现在 profile JSON。
- MAIN 只读取任务允许的两种来源:snapshot 走 `pageWindow.__conversationListData__``contact-observer/entry.ts:64-73`),更新订阅 `EventBus.on("im-conversation-list:syncData", ...)``:46-56`);没有调用 docs 中延期的详情微应用/`conversationServiceHttp`。同一 profile 的变化判断只使用 `seen[aliId]` fingerprint`:46-53`),snapshot 会先刷新 seen 再 emit`:69-72`)。
- 页面 bridge 在 MAIN sink 发布前再次用共享 page decoder`page-bridge/main.ts:45-57,130-139`);ISOLATED 只检查当前 window/origin、decode、方向,再原样 `Port.postMessage``page-bridge/isolated.ts:54-75`)。Profile envelope 的 profile object 复用 shared `isOneTalkContactProfile``page-bridge/model.ts:234-249`),且 outer key exact,故没有 raw row 通道。
### 5. First valid hello / account route evidence
当前生产组合路径是可达的:
```text
manifest document_start MAIN + ISOLATED
-> page-script-entry installs observer, profile command handler, and MAIN hello
-> ISOLATED Port -> Service Worker runtime registers hello identity
-> PageRuntimeHost.onPageIdentity
-> engine/profile handlePageReady
-> account-level routePageCommand({ channelAccountId, command: onetalk.contact.snapshot })
-> MAIN observer.snapshot()
-> profile-observed page envelope
```
证据分别为 `public/manifest.json:17-29``vite.config.ts:10-18``main-page/page-script-entry.ts:19-35``page-runtime-host.ts:28-36,73-90``runtime.ts:437-449,544-647``contact-observer/page-command.ts:7-19`。我执行了一个无写入的 `PageRuntimeHost` probe:注册 `account-1` hello 后,输出为 `calls=[["engine","account-1",null],["profile"]]``posted=["onetalk.contact.snapshot"]`,随后 command-result 成功解析为 `{channelAccountId:"account-1"}`。这证明当前实现确实会触发 snapshot;但现有测试只在 `onetalk-service-worker-runtime.test.js:185-214` 直接测试 runtime route,没有覆盖 PageRuntimeHost/controller 与 MAIN observer 的真实组合。
Account routing 是精确且不广播的:`runtime.ts:566-587` 对 account-level command 只接受同账号唯一 page;零页面为 `waiting_for_page`,多页面为 `ambiguous_page_route``runtime.ts:592-609` 对带会话命令继续做账号过滤和精确 conversation identity。登录账号只从 `currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId` 读取(`main-page/page-context.ts:24-44`),URL `activeAccountId` 仅被读取为 selected contact helper`:11-21`),不进入 profile channel scope`onetalk-page-bridge.test.js:207-222` 已验证 UserUtil fallback 与 URL 不回退。
## Invariants and Acceptance Probes
### Invariant owner
1. `contact-observer/model.ts` 是 row/contact → 白名单 profile、单聊过滤和 fingerprint 的 owner。
2. `apps/onetalk-contract` 是 profile fields、frame direction/scope 和 wire decoder 的 ownerF-007 表明完整 frame 的 exact-key 约束仍需补齐。
3. `page-bridge/model.ts` / `main.ts` / `isolated.ts` 是页面 envelope、source/origin/direction 和无状态转发的 owner。
4. `runtime.ts` / `page-runtime-host.ts` 是 page connection、account route 和 hello→snapshot 触发边界;其异步 callback 需要 connection/config epoch 作为同一 invariant 的一部分(F-008)。
5. `ContactProfileCoordinator` 与独立 profile object store 是 profile pending/ACK/reconnect owner;旧消息 `SyncEngine` 不应吸收 profile 状态。
### Sources of truth and async boundaries
- Source of truthOneTalk MAIN 的 `__conversationListData__``im-conversation-list:syncData`;不能从 message body、URL active account、DOM name/list position 或 Service Worker 反推。
- Account truth:页面 hello 的 logged-in accountprofile page envelope 不重复携带 account,由当前已注册 page connection 绑定到 account。
- Local delivery truth`storage.ts:15-22,95-103,154-172` 的独立 `onetalk_contact_profiles` storekey 由 `contactProfileKey(channelAccountId, aliId)` 生成;旧 message/candidate/checkpoint/anomaly stores 在 `:18-22,285-297` 保持独立。
- Async/side effectsMAIN `postMessage`、Port `postMessage`、profile ledger transaction、Bright `send`、profile ACK ledger transaction 都是不同边界。`contact-profile-coordinator.ts:148-196,198-219,221-265` 的 durable-first 与 ACK fingerprint fence 不应并入消息 ACK/checkpoint。
- Configuration/page race`runtime.ts` 的 connection identity 变化会收敛 command pending,但不能取消已启动的 host callback`configured-sync-session.ts:136-145` 会 dispose 旧 profile/engine,然而 host callback 仍动态取得 active objects。该边界可在仓库内用 fake delayed promises 证明,真实浏览器 tab navigation 仍需外部 smoke。
### Executed probes
```bash
# 共享 contract、page observer/coordinator、page bridge、runtime
node --experimental-strip-types --test \
apps/onetalk-contract/test/contract.test.ts \
apps/chrome-extension/test/onetalk-contact-profile-observer.test.js \
apps/chrome-extension/test/onetalk-contact-profile-coordinator.test.js \
apps/chrome-extension/test/onetalk-service-worker-runtime.test.js \
apps/chrome-extension/test/onetalk-page-bridge.test.js
# Result: 48 tests, 48 passed.
# Direct no-emit compiler checks (all passed in current worktree)
node_modules/.bin/tsc --noEmit -p apps/onetalk-contract/tsconfig.json
node_modules/.bin/tsc --noEmit -p apps/chrome-extension/tsconfig.json
node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json
```
### Required acceptance matrix
1. **Contract:** valid profile; missing/empty `aliId` or `conversationId`; nullable fields; invalid time/status/fingerprint; unknown field inside profile; unknown sensitive field at profile level and frame top level (F-007); wrong connection type/scope/version; empty/over-limit profile batch. Assert stable `invalid_message`/upgrade code and no secret echo.
2. **MAIN observer:** two singles + one group in initial map; snapshot before/after valid hello; repeated `syncData`; new `aliId`; changed/unchanged fingerprint; missing global/EventBus/login/cid/aliId; `pagehide`; sensitive row fixture. Assert only newly constructed whitelist profiles are posted.
3. **Bridge:** valid profile envelope crosses MAIN→ISOLATED→Port; wrong source/origin/direction/outer extra key/sensitive key is dropped; no profile envelope is accepted as a command or legacy observed message.
4. **Route:** zero/one/two same-account pages; other-account page; account snapshot; selected conversation changes; page hello A→B; Port replacement/disconnect; post failure. Assert exact same-account unique route only, no account fallback/broadcast, and stale result cannot resolve a new command.
5. **Hello/snapshot integration:** instantiate the same PageRuntimeHost/controller composition with a fake Port and MAIN command handler; send exactly one valid hello and assert one account-level snapshot command, then assert MAIN snapshot emits a profile envelope. Repeat hello/identity-change/config-replace cases to make duplicate and stale callback policy explicit.
6. **Async epoch:** hold engine/profile `handlePageReady()` promise, change page identity/configuration, release old promise; assert old callback cannot use the new engine/profile or send an old-account snapshot (F-008). Also assert cross-account profile message is rejected by `page-runtime-host.ts:61-63`/`sync-runtime.ts:88-91`.
7. **Isolation regression:** existing message observation remains routed through `runtime.ts:396-399` and `page-runtime-host.ts:41-54`; profile messages use `:400-402` and `:55-65`. `sync-engine.ts:224-254` handles only anchor/message ACK frames. Profile ledger store is separate, and no profile fields enter message/candidate/checkpoint/anomaly records. Run old message page bridge, sync engine, send confirmation, and storage tests together with the profile matrix.
## Caveats / Not Found
- No real Chromium session or built unpacked extension was opened in this recheck. The hello→snapshot host probe is an in-process deterministic probe; it does not prove the deployed OneTalk bundle still exposes the dated `__conversationListData__`/EventBus shape. `docs/onetalk-customer-profile-fetch.md:111-147,608-618` remains observational evidence, not a production guarantee.
- No real Mind endpoint, HTTP response contract, production authorization, body limit, or DB/upsert behavior was verified; those are outside this research boundary. This file only rechecks the plugin-side contract/page path and old-message isolation.
- `pnpm exec tsc --noEmit ...` was attempted but the local pnpm wrapper tried to fetch/install from `registry.npmmirror.com` and aborted module cleanup without a TTY. Direct `node_modules/.bin/tsc --noEmit` checks passed, so pnpm wrapper failure is an environment boundary rather than a current compiler failure.
- Existing `.trellis/tasks/08-31-onetalk-customer-profile-fetch/findings.md` still marks F-001/F-002/F-003/F-004/F-006 open even though current source and focused probes show fixes; it was not edited because this research is restricted to the requested file. F-005 remains genuinely open, and F-007/F-008 are new recheck findings.
- `git status --short` showed pre-existing task manifests, application/test edits, and new profile files; none were reverted or modified by this research. Only this research file was written.
@@ -0,0 +1,157 @@
# Research: OneTalk profile ledger/coordinator/runtime F-002 F-003 F-006 复核
- Query: 复核 Chrome Service Worker 的 profile observation 入 ledger、flush/requestId、ACK 事务提交、Bright 断线/重连、页面重新有效连接、同联系人新版本覆盖旧 pending 的完整状态机,并检查 configured session/runtime/sync runtime 是否污染消息同步与诊断。
- Scope: internal(当前 worktree 源码、测试、Trellis task/spec;未验证真实 Chromium、Bright 部署和 Mind endpoint
- Date: 2026-08-31
## Findings
### 1. 状态机与唯一 owner
当前 profile 状态的正确 owner 是 `contact-profile-coordinator.ts` + 独立 `onetalk_contact_profiles` store;消息 `SyncEngine/AckCompletionCoordinator` 不应参与 profile ACK。当前生产组合路径是:
```text
MAIN contact observer
-> page bridge profile-observed
-> SW runtime durable profile callback
-> ContactProfileCoordinator.putPendingProfile
-> Bright contact.profile.observed
-> contact.profile.ack
-> markProfileUploaded transaction
```
- MAIN 只把 profile 白名单构造成新对象;`contact-observer/entry.ts:46-53` 处理 `syncData``model.ts:58-96` 清洗字段并计算 fingerprint。
- Page bridge 的 profile envelope 是独立类型,`page-bridge/model.ts:43-48,234-251`Service Worker 在 `runtime.ts:310-329,396-402` 先调用 profile persistence,再调用 handler。
- Profile ledger 使用独立 store/version 4`storage.ts:15-22,285-297`;业务键是 `JSON.stringify([channelAccountId, aliId])``storage.ts:190-192``putPendingProfile` 只替换同联系人最新 pending`storage.ts:835-867`
- Bright profile helper 复用统一 `sendFrame` 的 socket/open/auth/scope/decode guards`bright-client.ts:507-560,810-819`;没有新增 WebSocket 或消息 candidate 路径。
- 生产 controller 通过 `sync-controller.ts:128-165` 创建 `OneTalkPageRuntimeHost` 和 configured sessionsession 为 profile 创建独立 coordinator、绑定 frame/status lifecycle`configured-sync-session.ts:136-145,162-245,265-279`
### 2. F-002:断线/重连/页面重新有效连接
原 finding 的“request map 在断线后阻挡重发”在当前 source 中已被修复,原 `findings.md:6` 证据已过时:
- Bright 状态变为非 authenticated 时清空内存 `requests` 和 follow-up 标记,authenticated 时从 durable pending 重新 flush`contact-profile-coordinator.ts:267-274`
- 页面 Port 断开同样只清内存 correlation,不清 ledger`contact-profile-coordinator.ts:281-285`runtime 的 disconnect 路径调用它,`page-runtime-host.ts:68-72`
- Bright close 将 socket 置空并进入 offline/unauthorized,之后由既有 bounded reconnect 机制恢复,`bright-client.ts:594-617`;收到 `ws.accepted` 后发出 authenticated 状态,`bright-client.ts:663-671`,从而触发 coordinator 的 durable flush。
- coordinator 的 flush 首先读 `listPendingProfiles`,再用当前 request map 过滤已在飞版本,`contact-profile-coordinator.ts:148-165`。request map 只记录 `requestId -> [(ledger key, aliId, fingerprint)]``contact-profile-coordinator.ts:169-175`SW 重启后 map 丢失但 pending 仍可重建 requestId。
- focused proof`apps/chrome-extension/test/onetalk-contact-profile-coordinator.test.js:129-145` 验证 Bright offline/authenticated 和 page disconnect 后重复发送 pending,测试通过(3/3 coordinator tests 全通过)。
因此 F-002 当前没有再现的本地实现缺陷。实现代理仍应保留一个明确的 `resetInFlightCorrelation` 语义,避免未来把 `requests.clear()` 改成清理 durable ledger;并增加“offline 发生在 flush list/read 与 send 之间、authenticated 在旧 flush finally 前恢复”的事件矩阵。
### 3. F-003ACK 与 IndexedDB commit fence
原 finding 的“`store.put` 后立即返回 true”在当前 source 中也已被修复,原 `findings.md:7` 证据已过时:
- `transactionResult` 只在 `oncomplete` resolve,在 `onerror/onabort` reject`storage.ts:353-360`
- `markProfileUploaded` 先按当前 ledger pending fingerprint 比较,只有相同才 `store.put` 新记录;函数等待 `completion` 后才返回 `shouldMark``storage.ts:870-895`。因此旧 ACK 对新 pending 是 no-op,当前 ACK 不能仅凭内存映射清除 ledger。
- coordinator 收 ACK 后删除内存 request mapping,再异步调用 `markProfileUploaded``contact-profile-coordinator.ts:221-245`commit 完成后才报 delivered/stale,失败进入 `ledger_failed``contact-profile-coordinator.ts:246-264`
当前实现需实现代理进一步确认/补强两点:
1. `markProfileUploaded` 的 boolean 应被明确命名为“事务已提交且仍匹配”,并由通用事务 helper 返回;不要让未来改动把 `shouldMark=true` 放在 completion fence 之前作为成功结果。
2. batch ACK 使用 `Promise.all` 启动多个独立 readwrite transaction`contact-profile-coordinator.ts:236-245`。这满足“每个 profile 只有 commit 后才算 delivered”,但不提供整批原子提交:若一个事务成功、另一个 abort,可能产生部分本地确认。若产品要求 batch all-or-nothing,应由 profile store owner 提供单一 readwrite transaction 的 `markProfileBatchUploaded`;若保持逐条提交,必须把部分成功作为明确可恢复状态并覆盖测试。
Focused storage test 当前不能作为 F-003 的通过证据:`node --experimental-strip-types --test test/onetalk-contact-profile-storage.test.js` 两个测试失败。根因是测试 `Factory` 只创建 profile store`storage.ts:341-345` 在 oldVersion 0 升级时按生产逻辑调用旧 message/candidate store 的 `openCursor`,而 `test/onetalk-contact-profile-storage.test.js:91-136` 没有这些 stores,且 fake `Store` 没有 `openCursor`;输出还出现测试结束后的异步 `TypeError: store.openCursor is not a function`。这是测试 fixture/migration modeling 缺口,不是当前 `markProfileUploaded` 已被证明回到 pre-commit return。
### 4. F-006:同联系人更新与并发 flush
原 finding 的“flush 完成后没有 follow-up”在当前 source 中已被修复,原 `findings.md:10` 证据已过时:
- `flushing` 是 single-flight;并发调用会设置 `followUpFlushRequested` 并等待当前 flush`contact-profile-coordinator.ts:148-155`
- 当前 flush finally 释放 in-flight 标记后,若期间有新观察,则只启动一次 follow-up flush`contact-profile-coordinator.ts:185-194`
- observation 每个 profile 先 `putPendingProfile`,再统一进入 flush`contact-profile-coordinator.ts:198-219`;同联系人新 fingerprint 会覆盖旧 pending,旧 fingerprint 的 ACK 由 store 比较拒绝,`storage.ts:847-862,883-895`
- focused proof`apps/chrome-extension/test/onetalk-contact-profile-coordinator.test.js:147-163` hold 住第一次 pending list,在旧版本 flush 期间写入新版本,最终发送 v1/v2,测试通过。
F-006 当前没有再现的本地实现缺陷。实现代理仍需增加多次连续更新(v2/v3/v4)、多个 chunk 中途更新,以及 update 在旧 ACK 的 ledger transaction 尚未完成时到达的矩阵;验收必须断言最终 durable pending 只有最后 fingerprint,且最后 fingerprint 必有后续 send。
### 5. 页面重新有效连接与 snapshot 触发
之前 F-001 的 production 路径缺失已经补上:`page-runtime-host.ts:73-87``sync-runtime.ts:76-87` 在有效 hello 后调用 `handlePageReady`、profile flush,再路由 account-level `onetalk.contact.snapshot`runtime 允许该 account-level command`runtime.ts:437-439,544-580`。页面 command handler 从 observer snapshot 发送当前已加载资料,`contact-observer/page-command.ts:7-19`
但当前 snapshot 调用有一个新的结构性阻塞风险:
- `requestProfileSnapshot` 等待 `routePageCommand` 完成,`page-runtime-host.ts:28-36``onPageIdentity``replayTo` 都等待它,`page-runtime-host.ts:73-87,98-107`
- controller 的 `configure` 等待 `pageHost.replayTo` 后才 `connectCurrent()``sync-controller.ts:157-165`。因此 stale page bundle、profile command handler 没安装、Port 丢失但未触发 disconnect,或命令结果永远不到达时,配置 promise 会悬挂,Bright 不会启动;这也会阻断 pending profile 的重连恢复和消息同步启动。
- 现有 sync-runtime test 的第二个用例在当前组合下被 cancelled(`test/onetalk-sync-runtime.test.js:197-225`):测试只回应了第一个 page command,却没有回应新增的 profile snapshot command,因 `configure` 一直等待第二个结果而未结束。该测试暴露了“profile snapshot 是核心启动链路的无限等待”问题;仅修改 fixture 让它回应第二个命令不足以解决 stale page 的生产风险。
需要实现代理采取的结构性修复:profile snapshot 应是独立、有限等待的 observation trigger,不得成为 engine/Bright configure 的 prerequisite。可选实现是为该 command 建立明确超时并转为安全 diagnostic,或 fire-and-forget 后由 profile observation/下一次有效 hello 恢复;必须保证 `connectCurrent()`、消息 bootstrap 和已有 message state machine 不依赖 snapshot result。不要把 snapshot failure 当成消息 sync failure,也不要把失败写入 message anomaly/candidate/checkpoint。
### 6. 账号边界的未覆盖竞态(高优先级)
当前实现存在比 F-002/F-006 更严重的 profile data-integrity gap
- `contact-observer/entry.ts:46-53``consumeUpdates``im-conversation-list:syncData` 直接解析和 emit,没有调用 `readChannelAccountId`;只有 `snapshot()``entry.ts:64-71` 检查登录账号。
- page profile envelope 只含 `profiles`,没有发送当时的 `channelAccountId``page-bridge/model.ts:43-48,234-251`
- SW runtime 只依据当前 Port 已注册的 `connection.channelAccountId` 接收 profile`runtime.ts:310-329`profile coordinator 再依据 configured scope 接收,`sync-runtime.ts:88-91``page-runtime-host.ts:55-64`
精确失败场景:Port 先以登录账号 A 完成 hello;随后 OneTalk 页面 logout、切换登录用户到 B,或 `currentUserAccountId` 短暂为空,但在新的 hello 到达前 EventBus 发布 `syncData`。observer 仍 emit B/未确认身份的 profileSW 仍把 envelope 归属于旧 connection A,并可能写入 A 的 ledger key。这样会把新账号资料污染到 A 的 profile ledger,且现有测试只覆盖 snapshot 缺失身份(`onetalk-contact-profile-observer.test.js:93-104`),没有覆盖 update path。
需要实现代理按一个 owner 修复:在 MAIN→bridge→SW profile envelope 上建立可验证的 page identity/epoch 边界。最低要求是 `syncData` 每次读取登录账号,缺失即丢弃;身份变化时先刷新 hello 并使旧 profile update 失效。更强且可证明的方案是在 profile page envelope 携带 `channelAccountId`,由 runtime 断言 envelope account 等于 Port hello account,再由 configured coordinator 断言等于当前 scope;任何不一致 fail closed。不要使用 URL `activeAccountId` 作 fallback。应加入“logout/切账号→syncData 先于 hello”和“旧 page profile 到达新 connection”的 negative tests。
### 7. 消息同步与诊断污染复核
未观察到 profile 直接污染消息状态的当前路径:
- configured session 的 Bright `onFrame` 先交给 profile coordinator,之后只对 `send.command` 做消息发送处理,`configured-sync-session.ts:173-219`profile ACK 不会进入 send confirmation。
- `OneTalkSyncEngine.handleBrightFrame` 只处理 `anchor.snapshot``message.ack``sync-engine.ts:224-254`profile frame 不推进 message candidate/checkpoint/completion。
- profile runtime 使用独立 profile store/coordinator`sync-runtime.ts:57-93`;没有调用 message `persistObservedBatch``AckCompletionCoordinator` 或 anomaly store。
- profile diagnostics 的 fieldNames 是固定白名单,`contact-profile-coordinator.ts:44-57,177-183`Bright diagnostics 只保留 frame/request/status 与 redaction metadata`bright-client.ts:448-482`。当前没有 profile body/raw HTTP body 进入消息诊断的代码证据。
仍有一项结构性维护风险:仓库同时保留直接组装 profile coordinator 的 `createOneTalkServiceWorkerSyncRuntime``sync-runtime.ts:45-97`)和 configured-session/controller 组装(`configured-sync-session.ts:162-245``sync-controller.ts:128-165`)。当前 `service-worker-entry.ts:110-133` 使用 controller 路径,另一工厂主要被测试/导出使用;两条路径未来容易产生不同的 status/reconnect/snapshot 语义。实现代理应指定 configured session/controller 为生产唯一 composition owner,或让另一工厂委托同一 owner,避免第二套 profile lifecycle。
## Invariants and Acceptance Probes
### Invariant owner
对每个 `[channelAccountId, aliId]` 必须成立:ledger 只保存该登录账号的最新清洗 profile;最新 fingerprint 在对应 Bright ACK 且本地 ACK transaction complete 前保持 pending;旧 ACK 不得确认新 pending;断线、SW 重启和页面重新有效连接只从 durable pending 恢复;profile 事件不得改变 message candidate/checkpoint/anomaly。
Owner 分工:MAIN observer 负责页面身份和白名单构造;page bridge/runtime 负责 identity/origin/direction gateprofile store 负责 durable record 和 commit fenceprofile coordinator 负责 flush/request correlation/recoveryconfigured session/controller 负责生命周期隔离;Bright client 负责唯一 socket 的 auth/scope/decode guardmessage SyncEngine 只处理 message/anchor frame。
### Snapshot / await / mutation / side-effect / rollback map
| 阶段 | 关键 await / snapshot | mutation / side effect | 回滚边界 |
| --- | --- | --- | --- |
| MAIN update | EventBus callback 读取 map;当前 update 未读取 login identity | 构造 profile、page postMessage | 身份缺失/变化必须 fail closed;不把 update 归属旧 Port |
| SW observation | runtime profile persistence await`runtime.ts:318-329` | `putPendingProfile` 写独立 store | IDB 失败不发送;消息 store 不变 |
| flush | `listPendingProfiles` awaitrequest map snapshotBright send | `contact.profile.observed` wire send、内存 request map | send false 不清 pending;离线/重启从 ledger 重建 |
| ACK | frame map lookup;每个 `markProfileUploaded` readwrite transaction await completion | 成功事务删除 pending/提升 fingerprint | fingerprint 不匹配 no-opabort/error 不报本地 delivered |
| Bright reconnect | close/status transitionsauthenticated callback | 新 socket/hellocoordinator flush | 只清内存 map,不清 durable pending |
| page reconnect | Port replacement/hello identitysnapshot command result 当前无限 await | page snapshot 触发 profile observation | snapshot command 不应阻塞 Bright/message lifecycleidentity mismatch 丢弃 |
| config replacement | session revision、旧 coordinator dispose、旧 bright disconnect | 替换 active session,复用按账号键的 profile store | 旧 profile callback 必须 no-opledger 不删除 |
### Deterministic acceptance matrix
1. **F-002 recovery**pending v1 → Bright offline → in-flight map clear → authenticatedBright send v1 exactly againSW restart with empty request map same resultpage disconnect/rehello same result;未 ACK 不得被删除。
2. **F-003 commit**profile pending → ACKhold readwrite completion 时 promise 不 resolvecomplete 后才 trueabort/error reject 且 pending 保留;旧 ACK 对新 fingerprint 返回 false。若保留逐条 transaction,测试 batch partial commit;若要求 all-or-nothing,测试单事务回滚。
3. **F-006 latest wins**v1 flush list held;依次观察 v2/v3;释放 list;最终发送 v1 后只发送 v3(或明确允许 v2 但最终 v3 必须发送),ledger pending 不得停在 v2ACK v1 后不得清 v3。
4. **Account race**Port hello Alogout/切 B 或身份为空;先发 syncData;断言 no ledger write/no Bright frame;随后 hello B 后只接受 B profile。旧 envelope 在新 Port/旧 Port 交错到达均 fail closed。
5. **Snapshot non-blocking**profile snapshot command 无结果、Port disconnect、stale page handler`configure()` 仍能完成并启动 Bright/message engine,错误只进独立 profile diagnostic;有效结果仍触发当前已加载快照。
6. **Message isolation**profile observed/ack、profile store abort、Bright reconnect、snapshot timeout 组合下,message candidate/checkpoint/anomaly、anchor、message ACK/completion 和 send three-state 快照均不改变。
## Verified Commands and Results
- `apps/chrome-extension/node_modules/.bin/tsc -p apps/chrome-extension/tsconfig.json --noEmit`pass。
- `apps/onetalk-contract/node_modules/.bin/tsc -p apps/onetalk-contract/tsconfig.json --noEmit`pass。
- `apps/server/node_modules/.bin/tsc -p apps/server/tsconfig.json --noEmit`pass。
- `node --experimental-strip-types --test test/onetalk-contact-profile-coordinator.test.js`cwd `apps/chrome-extension`):3/3 pass,覆盖 F-002/F-006 和 stale ACK。
- `node --experimental-strip-types --test test/onetalk-service-worker-runtime.test.js`14/14 pass,覆盖 profile durable gate、account snapshot route、Port lifecycle。
- `node --experimental-strip-types --test test/onetalk-configured-sync-session.test.js`2/2 pass;现有 send confirmation behavior unaffected。
- `node --experimental-strip-types --test test/onetalk-contact-profile-storage.test.js`2 failures;测试 fake IDB 未实现旧 stores/openCursor,不能作为生产 F-003 失败结论,但必须由实现代理修复 fixture 后重跑。
- `node --experimental-strip-types --test test/onetalk-sync-runtime.test.js`first lifecycle case passpage-prehello case cancelled because configure waits for an unhandled profile snapshot command result,必须修复/重新验证。
- `apps/chrome-extension/node_modules/.bin/tsc -p apps/chrome-extension/tsconfig.json --noEmit`、contract/server noEmit checks and `git diff --check` all passed;未运行 build/format/full suite/real browser/real Mind/PostgreSQL。
## External references
- `.trellis/tasks/08-31-onetalk-customer-profile-fetch/findings.md` — F-002/F-003/F-006 original finding ledger;其对应 source line evidence 已由当前修复 supersede。
- `.trellis/tasks/08-31-onetalk-customer-profile-fetch/prd.md``design.md``implement.md` — profile contract、生命周期和验收边界。
- `.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md``runtime-sync.md``runtime-diagnostics.md``page-bridge.md` — 当前消息 durable/lifecycle/diagnostic/page routing contracts;尚未包含独立 profile ledger 规范。
- 本研究未使用外部 web 文档,也未连接真实 Chromium/Bright/Mind。
## Caveats / Not Found
- F-002/F-006 当前实现与 focused tests 均显示已修复,但尚未有真实 Service Worker suspend/resume、真实 WebSocket reconnect 或真实 IndexedDB 浏览器事务证据。
- F-003 生产代码有 completion fence,但 storage test fixture 失败;batch 是否需要原子 ACK 是产品/实现决策,当前 PRD 只明确每条 profile 的 ACK 后本地状态。
- 当前最大未解决本地风险是页面切账号/logout 时 `syncData` update 没有显式登录账号 gate,可能造成跨账号 profile ledger 污染;这是 blocking data-integrity issue。
- snapshot command 当前会阻塞 configure/replay;真实页面通常会回 result,但 stale bundle/Port 丢失时的无限等待尚未被本地代码消除。
- `pnpm` wrapper 在本环境触发 registry metadata fetch 和非交互 install purge,未将其结果当作测试证据;直接使用现有 app-local Node/tsc 完成上述检查。
- 遵守研究边界:本次没有修改应用代码、测试代码、配置、spec、Git index 或其它 task 文件;只写入本研究文件。
@@ -0,0 +1,113 @@
# Research: server profile delivery validation recheck
- Query: 复核 apps/server 的 Mind profile HTTP adapter、WebSocket handler/dependencies/diagnostics、contract/server tests,以及 F-004/F-005;重点检查 binding/read/sync 授权、scope、HTTP response ACK、network/timeout、async stale connection/policy fence、敏感字段白名单和 Bright DB 写入边界。
- Scope: internal
- Date: 2026-08-31
## Findings
### Confirmed positive paths
- `apps/server/src/websocket/handler.ts:513-520` 先要求已认证连接的 `connectionType` 和完整 scope 与 frame 一致;`:523-525` 将 profile observation 限定为 plugin`:586-593` 以当前 frame scope 和握手保存的 binding 重新调用 plugin authorization,且 `authorizationOperationFor()``:299-305``contact.profile.observed` 映射为 `sync``:655-667` 要求 `read` permission。
- 授权返回后,`apps/server/src/websocket/handler.ts:620-653` 校验 binding、完整 Mind scope、authorization version`:675-701` 只从 registry 的 canonical plugin connection 生成 delivery context,因此不会采用客户端任意提交的 Mind 身份或 binding。
- `apps/server/src/mind-contact-profile.ts:5,36-41,48-57` 使用固定 endpointPOST body 顶层只有 `channelAccountId``binding``profiles`,不转发 Cookie/deviceId/mindUserId/workspaceId`:58-60` 收到任意 `Response` 都返回 delivered/status class,异常返回 `no_response`,不读取 response body。现有 adapter test `apps/server/test/mind-contact-profile.test.ts:26-53,55-90` 覆盖 200/204/400/404/500、网络异常和 body getter 未被访问。
- profile ACK 只在 delivery 返回 delivered 后执行;`apps/server/src/websocket/handler.ts:683-727` 对 no-response 不 ACK。`apps/server/src/websocket/diagnostics.ts:3-23` 的 profile event 只允许 event/requestId/connectionType/frameType/code/profileCount/durationMshandler `:705-713` 不把 body 或 frame payload 送入诊断;`:83-91` 隔离诊断 sink 异常。
- 当前 profile 分支不调用 `OneTalkService` 或 repository`apps/server/src/websocket/handler.ts:675-727` 只有 canonical context、guard、delivery 和 ACK`rg -n 'contact\\.profile|ContactProfile|profileFingerprint|contactProfile' apps/server/src/database apps/server/src/onetalk apps/server/drizzle` 无输出。因此可由现有代码证明该路径没有 Bright DB profile 写入,也没有 profile schema/migration。
- 依赖组合保持可替换:`apps/server/src/app.ts:29-39,77-95` 注入 `contactProfileDelivery`,否则仅在 Mind config 存在时创建 adapter`apps/server/src/websocket/index.ts:33-43,94-112,122-162` 将同一 delivery 注入每条 WS handler。没有 config/delivery 时 profile 分支不 ACK`:684-692`),不会伪造成功。
### F-004 recheck: resolved locally, final gate still incomplete
- 原 finding F-004(新增 server test 的 implicit any/unknown、readonly permission、fixture literal、`chatToken` 类型错误)在当前工作树不再复现。严格检查命令 `./node_modules/.bin/tsc --noEmit -p apps/onetalk-contract/tsconfig.json && ./node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json` 于本轮 exit 0;因此新增 `apps/server/test/mind-contact-profile.test.ts``apps/server/test/onetalk-profile-websocket.test.ts` 当前通过 strict TypeScript。
- 直接源码测试命令 `node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts`23/23 passed。完整 server 源测试 `node --experimental-strip-types --test apps/server/test/*.test.ts`75 passed、1 skipped;唯一 skip 是 `TEST_DATABASE_URL` 未设置的 PostgreSQL integration test。
- `pnpm exec tsc --noEmit -p apps/server/tsconfig.json` 未进入 TypeScript 编译:pnpm 尝试 GET registry metadata 后报 `ERR_PNPM_META_FETCH_FAIL`,随后因无 TTY 报 `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`。这是包管理器/网络环境边界,不应改写为编译失败;本地已存在的 tsc 直接调用才是本轮可执行的严格检查证据。
- 当前格式门禁仍失败:`./node_modules/.bin/oxfmt --check apps/server/src/mind-contact-profile.ts apps/server/src/app.ts apps/server/src/websocket/diagnostics.ts apps/server/src/websocket/handler.ts apps/server/src/websocket/index.ts apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts apps/onetalk-contract/src/model.ts apps/onetalk-contract/src/decoder.ts apps/onetalk-contract/test/contract.test.ts` 报格式问题于 `apps/onetalk-contract/src/decoder.ts``apps/server/src/websocket/handler.ts``apps/server/test/mind-contact-profile.test.ts``apps/server/test/onetalk-profile-websocket.test.ts`。研究代理未修改这些文件。
### F-005 recheck: open
- `git status --short -- prd.md docs .trellis/spec` 显示没有根 PRD、客户资料 docs 或规范文件改动;F-005 仍 open。
-`prd.md:127` R25b 仍写“本项目不自动创建客户或补全资料”,而本 task 新增了独立的基础 profile observation/delivery`prd.md:165` R38 仍只写禁止读取/重放 OneTalk CRM token,未澄清允许 MAIN world 读取页面已加载的白名单资料、但禁止 SW/server token replay`prd.md:224` AC43 仍写不会触发资料补全。至少需要在文档中区分“消息缺资料时不做详情补全”和“独立白名单 profile 观察投递”。
- `.trellis/spec/server/backend/mind-authorization.md:13-20,52-62` 只定义 binding/session 两个 Mind endpoint、授权 response 和 cookie 边界,没有 `/internal/bright/onetalk/contact-profiles` 的请求字段、任意 HTTP response ACK/no-response 语义、最大 batch/bytes 或 endpoint 认证约定;`.trellis/spec/server/backend/service-foundation.md:20-27``error-handling.md:35-79` 也没有 profile frame/delivery 失败场景。`logging-guidelines.md:27-44` 的安全诊断规则未声明 `profile_delivery`
- `docs/onetalk-customer-profile-fetch.md:601-618,672-674` 仍建议缺资料时调用 `conversationServiceHttp.getConversationContactDetailList`,并描述页面侧长等待/详情刷新;这与本 task PRD 的第一阶段“只读 global snapshot + syncData、不进入 detail refresh”不一致,且建议 payload 在 `:567-599` 仍是旧的非严格示例。应标注为历史探查/未来范围,而非当前 server contract。
### SRV-001: shared decoder misses profile direction validation (blocking)
- `apps/onetalk-contract/src/decoder.ts:265-291``hasValidFrameDirection()` 明确列出 message/sync/send 的方向,但遗漏 `contact.profile.observed``contact.profile.ack`;遗漏后落到 `return true``apps/onetalk-contract/src/decoder.ts:351-365` 只校验 profile payload,不补方向约束。
- 可执行复现命令:
```bash
node --experimental-strip-types --input-type=module -e 'import {decodeOneTalkFrame, ONETALK_PROTOCOL_VERSION} from "./apps/onetalk-contract/src/index.ts"; const profile={conversationId:"c",aliId:"a",accountId:null,loginId:null,name:null,companyName:null,countryCode:null,currentTimeZone:null,serviceType:null,observedAtMs:0,profileFingerprint:"fp",observationStatus:"confirmed"}; const base={protocolVersion:ONETALK_PROTOCOL_VERSION,requestId:"r",payload:{profiles:[profile]}}; console.log(JSON.stringify({observedOnMind:decodeOneTalkFrame({...base,connectionType:"mind_page",type:"contact.profile.observed",scope:{mindUserId:"u",workspaceId:"w",channelAccountId:"a"}}),ackOnPlugin:decodeOneTalkFrame({protocolVersion:ONETALK_PROTOCOL_VERSION,requestId:"r",connectionType:"plugin",type:"contact.profile.ack",scope:{channelAccountId:"a",deviceId:"d"},payload:{status:"delivered",profileCount:1}})}));'
```
Verified output has `"observedOnMind":{"ok":true}` and `"ackOnPlugin":{"ok":true}`. Handler-level `apps/server/src/websocket/handler.ts:523-525,528-531` happens to reject these later (plugin-only guard / client-frame allowlist), but the shared contract decoder itself accepts invalid directions, so contract consumers and tests can diverge. Existing contract test `apps/onetalk-contract/test/contract.test.ts:177-212` covers sensitive/incomplete profile payloads but not wrong connection types.
### SRV-002: revocation during Mind delivery await is not fenced (blocking)
- `apps/server/src/websocket/handler.ts:586-618` performs the plugin `sync` authorization and checks policy; `:620-681` checks binding/version/scope and creates a guard; `:697-704` then awaits the external delivery. After that await, `:714-725` only calls `commitGuard.assertValid()` before sending ACK.
- `apps/server/src/websocket/registry.ts:265-283` defines that guard as current registry identity, generation, OPEN socket, policy epoch, and policy admission. It does not re-read authorization, binding, authorizationVersion, or read permission. Consequently, if the Mind authorization reader revokes or changes the binding while `contactProfileDelivery` is pending, but the socket remains registered and cutover epoch is unchanged, the guard passes and `contact.profile.ack` is sent at handler `:721-725`.
- Policy pause and socket replacement are fenced: `registry.ts:415-455` unregisters/invalidates generation on close/pause, and the same guard observes that; this is not equivalent to a remote authorization revocation. The implementation plan explicitly requires pause/revoke/connection replacement late-ACK coverage at `.trellis/tasks/08-31-onetalk-customer-profile-fetch/implement.md:37-44`.
- Current WS tests `apps/server/test/onetalk-profile-websocket.test.ts:163-245` cover canonical auth, invalid payload, and returned no-response, but no latch test revokes/upserts authorization during delivery. A deterministic probe should hold delivery on a Promise, revoke or change the mock record, resolve delivered, and assert no profile ACK (and a stable close/diagnostic outcome as chosen by the contract).
### SRV-003: adapter uses a runtime spread instead of a field whitelist (blocking security boundary)
- `apps/server/src/mind-contact-profile.ts:36-41` serializes each runtime profile with `{ ...profile }`. The compile-time `OneTalkContactProfile` type and WS decoder exact-key check make the normal decoded WS path safe, but this HTTP adapter is itself the external boundary and accepts runtime JavaScript values. It does not explicitly pick the 13 approved profile keys.
- Direct executable proof (bypassing TypeScript only to test the runtime boundary):
```bash
node --experimental-strip-types --input-type=module -e 'import {createMindContactProfileDelivery} from "./apps/server/src/mind-contact-profile.ts"; let sent=""; const delivery=createMindContactProfileDelivery({baseUrl:"https://mind.example.com",timeoutMs:100,fetch:async (_input,init)=>{sent=String(init?.body); return {status:200};}}); await delivery({channelAccountId:"a",binding:"b",profiles:[{conversationId:"c",aliId:"i",accountId:null,loginId:null,name:null,companyName:null,countryCode:null,currentTimeZone:null,serviceType:null,observedAtMs:0,profileFingerprint:"fp",observationStatus:"confirmed",chatToken:"secret"}]}); console.log(sent);'
```
Verified output includes `"chatToken":"secret"`. The current WS decoder rejects that shape before handler delivery (`decoder.ts:152-170,351-357`), so this is a defense-in-depth/adapter contract gap rather than a currently reachable valid-frame injection; however the explicit dependency port and any future caller can bypass it.
### SRV-004: profile test matrix is narrower than acceptance contract
- Existing tests prove source parsing and basic server flow, but do not separately exercise HTTP status classes `1xx` and `3xx` (`mind-contact-profile.test.ts:26-53`), an actual AbortSignal timeout, a delivery function that rejects, stale authorization revocation/version change during delivery, connection replacement during delivery, or policy pause during delivery. They also do not assert profile-specific diagnostics contain no sensitive keys, nor instrument the service/database stub to prove no profile path call.
- The implementation is currently covered for network exception/no-response and no ACK (`mind-contact-profile.test.ts:55-90`, `onetalk-profile-websocket.test.ts:225-245`), but the missing latch and adapter-runtime tests leave SRV-002/SRV-003 unguarded. `apps/server/test/onetalk-profile-websocket.test.ts:60-63` uses an empty DB stub and `:65-96` a service fixture; absence of a call is inferred from handler control flow, not asserted by a spy.
## Invariants and Acceptance Probes
### Invariant owner
The primary server invariant is: a valid profile frame is accepted only from the canonical, currently authorized plugin for the exact plugin scope; the Mind request body contains only the approved account/binding/profile fields; an ACK means only that a Mind HTTP response was received (any status class), while network/timeout/no response never ACKs; stale connection, policy epoch, and authorization state cannot emit a late ACK; Bright does not persist profile facts.
Ownership should remain split: `apps/onetalk-contract/src/decoder.ts` owns frame shape/direction/scope typing; `apps/server/src/websocket/handler.ts` owns admission and canonical binding/read authorization; `apps/server/src/websocket/registry.ts` owns connection/generation/policy fences; `apps/server/src/mind-contact-profile.ts` owns HTTP body/status/no-body-read boundary; `apps/server/src/websocket/diagnostics.ts` owns the safe diagnostic schema; Mind owns final profile upsert/idempotency and business authorization semantics externally.
### Sources of truth
- Plugin scope and frame shape: shared contract decoder, not ad hoc handler checks.
- Binding/read authorization: current authorization reader result plus the registry canonical plugin connection; client frame scope is an input to validate, not an authority source.
- Delivery result: adapter's response/no-response result; response body and status meaning beyond class are not consumed locally.
- Persistence: no profile source of truth in Bright; Mind endpoint/DB remains external.
### Async / side-effect / rollback map
1. Decode is synchronous and side-effect free; invalid direction/payload must stop before authorization or delivery.
2. Handler authorization awaits at `handler.ts:586-618`; then binding/scope/version/canonical checks at `:620-681` are the admission fence.
3. `contactProfileDelivery` at `handler.ts:697-704` is the irreversible external HTTP side effect. During this await, pause/close/replacement can invalidate the registry guard; remote authorization revocation currently cannot unless it also changes policy/connection.
4. ACK at `handler.ts:721-725` is the only server response side effect for profile. It must follow a complete current-state fence and must not imply Mind DB commit. There is no Bright DB mutation or rollback boundary in this path.
### Deterministic acceptance matrix
- Contract: valid plugin observed → accepted; observed on mind page and profile ACK on plugin → invalid direction; unknown profile key/token → invalid message; wrong account/device scope → invalid or scope rejection; no sensitive value in decoder error.
- Authorization: no auth, wrong binding, wrong channel/device scope, missing `read`, revoked binding, and changed authorization version → no Mind call/no ACK; valid canonical plugin with `sync` + `read` → delivery allowed.
- HTTP: response statuses 100/200/204/302/400/404/500 all produce delivered status class and ACK; fetch reject, AbortSignal timeout, and no response produce no-response/no ACK; body getter/read method must never be touched.
- Async fence: latch delivery; independently pause policy, close/replace socket, revoke binding, and change authorization version; resolve delivery as delivered; assert no late ACK and stable diagnostic/close semantics. Repeat with delivery already resolved before fence to document the unavoidable external `delivery_unknown` boundary.
- Security/persistence: pass a runtime profile carrying `chatToken`, encrypted IDs, Cookie, deviceId, mindUserId, workspaceId, and raw response to the adapter; assert outgoing JSON excludes them. Spy all service/repository/database methods and assert profile handling invokes none.
- Regression: run the existing message/anchor/send WebSocket tests and ensure no profile fields enter message/candidate/checkpoint/anomaly/schema paths.
### Feasibility / locus of control
This repository can prove decoder shape/direction, handler authorization call inputs, canonical scope selection, local policy/generation fences, outgoing body construction, no response-body read, no Bright DB call, and fake HTTP/WS outcomes. It cannot prove that `/internal/bright/onetalk/contact-profiles` exists in Mind, accepts binding-only authentication, honors the exact body/status contract, performs the intended upsert/idempotency/manual-field policy, or has a production timeout/max-body/SLA. It also cannot prove real Chromium page data, production TLS/CORS/Cookie behavior, or PostgreSQL integration without those environments.
## External References
- No web or external documentation was used in this recheck.
- Internal references: `.trellis/tasks/08-31-onetalk-customer-profile-fetch/prd.md`, `design.md`, `implement.md`, `findings.md`; `prd.md`; `docs/onetalk-customer-profile-fetch.md`; `.trellis/spec/server/backend/{mind-authorization,error-handling,logging-guidelines,quality-guidelines,service-foundation}.md`.
- The Mind profile endpoint path is only locally defined by `apps/server/src/mind-contact-profile.ts:5`; its production existence, authentication, response semantics, limits and business behavior remain unverified external boundaries.
## Caveats / Not Found
- F-004 is recorded as open in the task finding ledger, but its original strict TypeScript errors are resolved in the current worktree; the direct local compiler checks above are the current evidence. Full `pnpm` orchestration was blocked by pnpm registry metadata/network and non-TTY dependency-removal behavior.
- F-005 remains open: no root PRD/spec synchronization was found, and the internal profile exploration document still contains out-of-scope detail-refresh guidance.
- No real Mind endpoint, Chromium session, production WebSocket, or PostgreSQL integration was used. The one PostgreSQL test was skipped because `TEST_DATABASE_URL` was unset.
- `git diff --check` passed for tracked changes in this worktree. It does not inspect untracked files; the scoped Oxfmt check above is the relevant formatting result and failed on four changed files.
- All pre-existing application, test, task, and untracked changes were left untouched. This research turn writes only this file.
@@ -3,7 +3,7 @@
"name": "onetalk-customer-profile-fetch",
"title": "OneTalk 客户资料采集与 Mind 写入",
"description": "第一阶段从 OneTalk 页面采集基础单聊资料,复用 Bright WebSocket 批量/增量投递,由 Mind 通过 binding 认证的 HTTP endpoint 接收;完整详情延期,先完成跨层合同与方案确认。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": "cross-package",
"package": null,
@@ -33,4 +33,4 @@
],
"notes": "",
"meta": {}
}
}
@@ -0,0 +1,105 @@
// 观察 OneTalk 页面已经加载的联系人资料
import type { OneTalkContactProfile } from "@trade-message-center/onetalk-contract";
import { readChannelAccountId } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
import { isRecord } from "../../../lib/guards.ts";
import { profilesFromConversationMap } from "./model.ts";
type ProfileSink = (profiles: OneTalkContactProfile[], channelAccountId: string) => void;
type PageEventBus = {
on?: (event: string, listener: (value: unknown) => void) => unknown;
};
export type OneTalkContactProfileObserver = {
snapshot: () => OneTalkContactProfile[];
dispose: () => void;
};
const eventBusFor = (pageWindow: OneTalkPageWindow): PageEventBus | null => {
return isRecord(pageWindow.EventBus) ? (pageWindow.EventBus as PageEventBus) : null;
};
const unsubscribeFor = (value: unknown): (() => void) | undefined => {
return typeof value === "function" ? (value as () => void) : undefined;
};
/** 安装独立联系人观察器;初始快照仅由 account-level command 触发。 */
export const installOneTalkContactProfileObserver = (
pageWindow: OneTalkPageWindow,
sink: ProfileSink,
onDiagnostic?: (event: { code: string; fields?: readonly string[] }) => void,
): OneTalkContactProfileObserver => {
let stopped = false;
let observedChannelAccountId: string | undefined;
const seen = new Map<string, string>();
const now = (): number => Date.now();
const emit = (profiles: OneTalkContactProfile[], channelAccountId: string): void => {
if (stopped || profiles.length === 0 || channelAccountId.length === 0) return;
try {
sink(
profiles.map((profile) => ({ ...profile })),
channelAccountId,
);
} catch {
onDiagnostic?.({ code: "profile_sink_failed" });
}
};
const consumeUpdates = (value: unknown): void => {
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) {
observedChannelAccountId = undefined;
seen.clear();
onDiagnostic?.({ code: "profile_login_identity_missing" });
return;
}
if (
observedChannelAccountId !== undefined &&
observedChannelAccountId !== channelAccountId
) {
observedChannelAccountId = channelAccountId;
seen.clear();
onDiagnostic?.({ code: "profile_login_identity_changed" });
return;
}
observedChannelAccountId = channelAccountId;
const profiles = profilesFromConversationMap(value, now());
const changed = profiles.filter((profile) => {
const previous = seen.get(profile.aliId);
seen.set(profile.aliId, profile.profileFingerprint);
return previous !== profile.profileFingerprint;
});
emit(changed, channelAccountId);
};
const eventBus = eventBusFor(pageWindow);
const unsubscribe = eventBus?.on?.("im-conversation-list:syncData", consumeUpdates);
const onPageHide = (): void => {
stopped = true;
unsubscribeFor(unsubscribe)?.();
};
pageWindow.addEventListener("pagehide", onPageHide);
return {
snapshot: () => {
const channelAccountId = readChannelAccountId(pageWindow);
if (stopped || !channelAccountId) {
observedChannelAccountId = undefined;
seen.clear();
onDiagnostic?.({ code: "profile_login_identity_missing" });
return [];
}
if (observedChannelAccountId !== channelAccountId) seen.clear();
observedChannelAccountId = channelAccountId;
const profiles = profilesFromConversationMap(
pageWindow.__conversationListData__,
now(),
);
for (const profile of profiles) seen.set(profile.aliId, profile.profileFingerprint);
emit(profiles, channelAccountId);
return profiles;
},
dispose: onPageHide,
};
};
@@ -0,0 +1,111 @@
// 将 OneTalk 已加载会话条目清洗为跨边界联系人资料
import {
type OneTalkContactProfile,
ONETALK_CONTACT_PROFILE_STATUSES,
} from "@trade-message-center/onetalk-contract";
import { isRecord } from "../../../lib/guards.ts";
const PROFILE_FINGERPRINT_FIELDS = [
"conversationId",
"aliId",
"accountId",
"loginId",
"name",
"companyName",
"countryCode",
"currentTimeZone",
"serviceType",
] as const;
const readString = (value: unknown): string | null => {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return null;
};
const readOptionalString = (value: unknown): string | null =>
typeof value === "string" ? value.trim() || null : null;
const readOptionalNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const isGroupConversation = (row: Record<string, unknown>): boolean => {
if (row.isGroup === true || row.groupId !== undefined || row.groupType !== undefined) {
return true;
}
const type = readString(row.conversationType)?.toLowerCase();
return type === "group" || type === "群聊";
};
const fingerprintFor = (
profile: Omit<
OneTalkContactProfile,
"observedAtMs" | "profileFingerprint" | "observationStatus"
>,
): string => {
const canonical = JSON.stringify(PROFILE_FINGERPRINT_FIELDS.map((field) => profile[field]));
let hash = 2166136261;
for (let index = 0; index < canonical.length; index += 1) {
hash ^= canonical.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `v1-${(hash >>> 0).toString(16).padStart(8, "0")}`;
};
const readProfileField = (
contact: Record<string, unknown>,
row: Record<string, unknown>,
key: string,
): unknown => contact[key] ?? row[key];
/** 从页面条目构造严格白名单 profile;原始 row 永远不会作为结果返回。 */
export const profileFromConversationRow = (
rowValue: unknown,
observedAtMs: number,
): OneTalkContactProfile | null => {
if (!isRecord(rowValue) || isGroupConversation(rowValue)) return null;
const contact = isRecord(rowValue.contact) ? rowValue.contact : rowValue;
const conversationId = readString(rowValue.cid);
const aliId = readString(readProfileField(contact, rowValue, "aliId"));
if (!conversationId || !aliId || !Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
return null;
}
const profile = {
conversationId,
aliId,
accountId: readString(readProfileField(contact, rowValue, "accountId")),
loginId: readOptionalString(readProfileField(contact, rowValue, "loginId")),
name: readOptionalString(readProfileField(contact, rowValue, "name")),
companyName: readOptionalString(readProfileField(contact, rowValue, "companyName")),
countryCode: readOptionalString(
readProfileField(contact, rowValue, "complianceCountryCode") ??
readProfileField(contact, rowValue, "countryCode"),
),
currentTimeZone: readOptionalNumber(readProfileField(contact, rowValue, "currentTimeZone")),
serviceType: readOptionalString(readProfileField(contact, rowValue, "serviceType")),
} satisfies Omit<
OneTalkContactProfile,
"observedAtMs" | "profileFingerprint" | "observationStatus"
>;
const complete = Object.values(profile).every((value) => value !== null);
return {
...profile,
observedAtMs,
profileFingerprint: fingerprintFor(profile),
observationStatus: complete ? ONETALK_CONTACT_PROFILE_STATUSES[0] : "partial",
};
};
export const profilesFromConversationMap = (
value: unknown,
observedAtMs: number,
): OneTalkContactProfile[] => {
if (!isRecord(value)) return [];
return Object.values(value).flatMap((row) => {
const profile = profileFromConversationRow(row, observedAtMs);
return profile === null ? [] : [profile];
});
};
@@ -0,0 +1,19 @@
// 处理 Service Worker 请求的联系人资料快照命令
import type { OneTalkPageCommandMessage, PageCommandResult } from "../../page-bridge/model.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkContactProfileObserver } from "./entry.ts";
/** 执行联系人资料 account-level snapshot 命令。 */
export const handleOneTalkContactProfileCommand = (
_pageWindow: OneTalkPageWindow,
message: OneTalkPageCommandMessage,
observer: OneTalkContactProfileObserver,
): PageCommandResult | null => {
if (message.command.action !== "onetalk.contact.snapshot") return null;
const profiles = observer.snapshot();
return {
status: "completed",
profileCount: profiles.length,
};
};
@@ -5,10 +5,14 @@ export type OneTalkAccountId = string | number;
export type OneTalkPageWindow = {
location: Pick<Location, "href">;
document?: Pick<Document, "querySelectorAll" | "addEventListener">;
addEventListener(type: "pagehide", listener: () => void): void;
IcbuIM?: unknown;
__tradeMessageCenterOneTalk?: unknown;
/** OneTalk runtime's logged-in account identifier. */
currentUserAccountId?: unknown;
/** OneTalk's already-loaded conversation list; read only in MAIN world. */
__conversationListData__?: unknown;
EventBus?: unknown;
};
/** 判断值是否符合 OneTalk 账号标识形状。 */
@@ -4,16 +4,30 @@ import { installCurrentConversationHistorySync } from "./current-conversation-hi
import { handleOneTalkHistoryCommand } from "./current-conversation-history/page-command.ts";
import { installOneTalkMessageObserver } from "./message-observer/entry.ts";
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
import {
installOneTalkContactProfileObserver,
type OneTalkContactProfileObserver,
} from "./contact-observer/entry.ts";
import { handleOneTalkContactProfileCommand } from "./contact-observer/page-command.ts";
import {
createOneTalkPageObservedSink,
createOneTalkPageProfileObservedSink,
installOneTalkMainPageBridge,
} from "../page-bridge/main.ts";
/** 启动 main-page 目录下的全部 OneTalk 页面能力。 */
const installOneTalkPageFeatures = (): void => {
const sendObservation = createSendObservationCorrelator();
installOneTalkMainPageBridge(window, (message) =>
handleOneTalkHistoryCommand(window, message, sendObservation),
let profileObserver: OneTalkContactProfileObserver;
profileObserver = installOneTalkContactProfileObserver(
window,
createOneTalkPageProfileObservedSink(window),
);
installOneTalkMainPageBridge(
window,
(message) =>
handleOneTalkContactProfileCommand(window, message, profileObserver) ??
handleOneTalkHistoryCommand(window, message, sendObservation),
);
const observedSink = createOneTalkPageObservedSink(window);
installOneTalkMessageObserver(window, (batch) => {
@@ -3,6 +3,7 @@
import { readChannelAccountId, readConversationSelection } from "../main-page/page-context.ts";
import type { HistoryPageProgress } from "../main-page/current-conversation-history/model.ts";
import type { OneTalkObservedMessageSink } from "../main-page/message-observer/model.ts";
import type { OneTalkContactProfile } from "@trade-message-center/onetalk-contract";
import {
createOneTalkPageCommandResultMessage,
createOneTalkPageHelloMessage,
@@ -14,6 +15,7 @@ import {
type OneTalkPageBridgeWindow,
type OneTalkPageMessage,
type OneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
type PageCommandResult,
} from "./model.ts";
@@ -125,6 +127,22 @@ export const createOneTalkPageHistoryProgressSink = (
};
};
/** 创建把清洗后的联系人资料发布到当前页面 origin 的 MAIN sink。 */
export const createOneTalkPageProfileObservedSink = (
pageWindow: OneTalkPageBridgeWindow,
): ((profiles: OneTalkContactProfile[], channelAccountId: string) => void) => {
return (profiles, channelAccountId) => {
if (profiles.length === 0 || channelAccountId.length === 0) return;
const origin = pageOrigin(pageWindow);
if (!origin) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageProfileObservedMessage(profiles, channelAccountId),
);
};
};
/** 安装 MAIN 页面注册与可注入命令消费者。 */
export const installOneTalkMainPageBridge = (
pageWindow: OneTalkPageBridgeWindow,
@@ -1,6 +1,10 @@
// 定义 OneTalk 页面桥共享消息契约
import { isRecord } from "../../lib/guards.ts";
import {
isOneTalkContactProfile,
type OneTalkContactProfile,
} from "@trade-message-center/onetalk-contract";
import type { ObservedOneTalkMessage } from "../main-page/message-observer/model.ts";
export const ONE_TALK_PAGE_BRIDGE_SOURCE = "trade-message-center.onetalk.page-bridge";
@@ -36,6 +40,14 @@ export type OneTalkPageObservedMessage = {
historyProgress?: OneTalkPageHistoryProgress;
};
export type OneTalkPageProfileObservedMessage = {
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
type: "onetalk.page.profile-observed";
channelAccountId: string;
profiles: OneTalkContactProfile[];
};
export type OneTalkPageHistoryProgress = {
conversationId: string;
page: number;
@@ -65,6 +77,7 @@ export type OneTalkPageCommandResultMessage = {
export type OneTalkPageMessage =
| OneTalkPageHelloMessage
| OneTalkPageObservedMessage
| OneTalkPageProfileObservedMessage
| OneTalkPageCommandMessage
| OneTalkPageCommandResultMessage;
@@ -219,6 +232,28 @@ const decodeObservedMessageEnvelope = (
};
};
const decodeProfileObservedMessage = (
value: Record<string, unknown>,
): OneTalkPageProfileObservedMessage | null => {
const keys = Object.keys(value).sort();
if (
keys.join(",") !== "channelAccountId,profiles,source,type,version" ||
typeof value.channelAccountId !== "string" ||
value.channelAccountId.trim().length === 0 ||
!Array.isArray(value.profiles) ||
!value.profiles.every(isOneTalkContactProfile)
) {
return null;
}
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.profile-observed",
channelAccountId: value.channelAccountId,
profiles: value.profiles.map((profile) => ({ ...profile })),
};
};
const decodeCommandMessage = (value: Record<string, unknown>): OneTalkPageCommandMessage | null => {
if (
typeof value.requestId !== "string" ||
@@ -269,6 +304,8 @@ export const decodeOneTalkPageMessage = (value: unknown): OneTalkPageMessage | n
return decodeHelloMessage(value);
case "onetalk.page.observed":
return decodeObservedMessageEnvelope(value);
case "onetalk.page.profile-observed":
return decodeProfileObservedMessage(value);
case "onetalk.page.command":
return decodeCommandMessage(value);
case "onetalk.page.command-result":
@@ -325,6 +362,20 @@ export const createOneTalkPageObservedMessage = (
};
};
/** 创建只包含清洗后联系人资料的页面观察消息。 */
export const createOneTalkPageProfileObservedMessage = (
profiles: OneTalkContactProfile[],
channelAccountId: string,
): OneTalkPageProfileObservedMessage => {
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.profile-observed",
channelAccountId,
profiles: profiles.map((profile) => ({ ...profile })),
};
};
/** 创建发送到页面的命令消息。 */
export const createOneTalkPageCommandMessage = (
requestId: string,
@@ -356,8 +407,15 @@ export const createOneTalkPageCommandResultMessage = (
/** 判断消息是否属于页面观察与注册方向。 */
export const isOneTalkPageObservationMessage = (
message: OneTalkPageMessage,
): message is OneTalkPageHelloMessage | OneTalkPageObservedMessage => {
return message.type === "onetalk.page.hello" || message.type === "onetalk.page.observed";
): message is
| OneTalkPageHelloMessage
| OneTalkPageObservedMessage
| OneTalkPageProfileObservedMessage => {
return (
message.type === "onetalk.page.hello" ||
message.type === "onetalk.page.observed" ||
message.type === "onetalk.page.profile-observed"
);
};
/** 判断消息是否允许从页面桥送入 MAIN。 */
@@ -380,6 +438,7 @@ export const isOneTalkMainToIsolatedMessage = (
): message is
| OneTalkPageHelloMessage
| OneTalkPageObservedMessage
| OneTalkPageProfileObservedMessage
| OneTalkPageCommandResultMessage => {
return isOneTalkPageObservationMessage(message) || isOneTalkPageCommandResultMessage(message);
};
@@ -2,11 +2,13 @@
import {
ONETALK_PROTOCOL_VERSION,
createOneTalkContactProfileObservedFrame,
decodeOneTalkFrame,
isSameOneTalkScope,
type OneTalkPluginScope,
type OneTalkFrame,
type OneTalkObservedMessage,
type OneTalkContactProfile,
type OneTalkPermission,
type OneTalkSyncAnomalyCode,
type OneTalkSyncMode,
@@ -143,6 +145,10 @@ export type OneTalkBrightClient = {
message: OneTalkObservedMessage;
requestId?: string;
}) => string | null;
sendContactProfiles: (input: {
profiles: OneTalkContactProfile[];
requestId?: string;
}) => string | null;
sendSyncComplete: (input: {
conversationId: string;
mode: OneTalkSyncMode;
@@ -336,6 +342,16 @@ const makeMessageObservedFrame = (
};
};
const makeContactProfilesFrame = (
scope: OneTalkPluginScope,
requestId: string,
profiles: OneTalkContactProfile[],
): OneTalkFrame =>
createOneTalkContactProfileObservedFrame(
{ connectionType: "plugin", requestId, scope },
profiles,
);
const makeSyncCompleteFrame = (
scope: OneTalkPluginScope,
requestId: string,
@@ -791,6 +807,17 @@ export const createOneTalkBrightClient = (
: null;
};
const sendContactProfiles = (input: {
profiles: OneTalkContactProfile[];
requestId?: string;
}): string | null => {
const requestId = input.requestId ?? createRequestId("profile");
if (!isValidRequestId(requestId) || input.profiles.length === 0) return null;
return sendFrame(makeContactProfilesFrame(options.scope, requestId, input.profiles))
? requestId
: null;
};
const sendSyncComplete = (input: {
conversationId: string;
mode: OneTalkSyncMode;
@@ -848,6 +875,7 @@ export const createOneTalkBrightClient = (
subscribeStatus,
sendConversationDiscovered,
sendMessageObserved,
sendContactProfiles,
sendSyncComplete,
sendSendConfirmation,
};
@@ -19,15 +19,24 @@ import {
type OneTalkSyncEngineStatus,
} from "./sync-engine.ts";
import { createOneTalkSyncStore, type OneTalkSyncStore } from "./storage.ts";
import { createOneTalkContactProfileStore, type OneTalkContactProfileStore } from "./storage.ts";
import {
createOneTalkContactProfileCoordinator,
type OneTalkContactProfileCoordinator,
type OneTalkContactProfileDiagnostic,
} from "./contact-profile-coordinator.ts";
export type OneTalkActiveSyncSession = {
bright: OneTalkBrightClient;
engine: OneTalkSyncEngine;
profile?: OneTalkContactProfileCoordinator;
disposeProfileStatus: () => void;
};
export type OneTalkConfiguredSyncSessionOptions = {
createBrightClient?: (options: OneTalkBrightClientOptions) => OneTalkBrightClient;
createStore?: () => OneTalkSyncStore;
createProfileStore?: () => OneTalkContactProfileStore;
pageRuntime: Pick<OneTalkServiceWorkerRuntime, "routePageCommand">;
onBeforeChange?: () => void;
onStatusChange: () => void;
@@ -36,6 +45,7 @@ export type OneTalkConfiguredSyncSessionOptions = {
onBrightDiagnostic?: (event: OneTalkBrightDiagnostic) => void;
onPageDiagnostic?: (event: OneTalkPageDiagnostic) => void;
onEngineDiagnostic?: (event: OneTalkSyncEngineDiagnostic) => void;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
now?: () => number;
createRequestId?: (kind: string) => string;
};
@@ -59,6 +69,7 @@ export class OneTalkConfiguredSyncSession {
options: OneTalkBrightClientOptions,
) => OneTalkBrightClient;
private readonly createStore: () => OneTalkSyncStore;
private readonly createProfileStore: (() => OneTalkContactProfileStore) | undefined;
private readonly pageRuntime: Pick<OneTalkServiceWorkerRuntime, "routePageCommand">;
private readonly onBeforeChange: (() => void) | undefined;
private readonly onStatusChange: () => void;
@@ -67,9 +78,13 @@ export class OneTalkConfiguredSyncSession {
private readonly onBrightDiagnostic: ((event: OneTalkBrightDiagnostic) => void) | undefined;
private readonly onPageDiagnostic: ((event: OneTalkPageDiagnostic) => void) | undefined;
private readonly onEngineDiagnostic: ((event: OneTalkSyncEngineDiagnostic) => void) | undefined;
private readonly onProfileDiagnostic:
| ((event: OneTalkContactProfileDiagnostic) => void)
| undefined;
private readonly now: (() => number) | undefined;
private readonly createRequestId: ((kind: string) => string) | undefined;
private store: OneTalkSyncStore | null = null;
private profileStore: OneTalkContactProfileStore | null = null;
private active: OneTalkActiveSyncSession | null = null;
private currentConfig: OneTalkExtensionConfig | null = null;
private revision = 0;
@@ -77,6 +92,11 @@ export class OneTalkConfiguredSyncSession {
public constructor(options: OneTalkConfiguredSyncSessionOptions) {
this.createBrightClient = options.createBrightClient ?? createOneTalkBrightClient;
this.createStore = options.createStore ?? (() => createOneTalkSyncStore());
this.createProfileStore =
options.createProfileStore ??
(typeof globalThis.indexedDB === "undefined"
? undefined
: () => createOneTalkContactProfileStore());
this.pageRuntime = options.pageRuntime;
this.onBeforeChange = options.onBeforeChange;
this.onStatusChange = options.onStatusChange;
@@ -85,6 +105,7 @@ export class OneTalkConfiguredSyncSession {
this.onBrightDiagnostic = options.onBrightDiagnostic;
this.onPageDiagnostic = options.onPageDiagnostic;
this.onEngineDiagnostic = options.onEngineDiagnostic;
this.onProfileDiagnostic = options.onProfileDiagnostic;
this.now = options.now;
this.createRequestId = options.createRequestId;
}
@@ -97,6 +118,10 @@ export class OneTalkConfiguredSyncSession {
return this.currentConfig;
}
public getRevision(): number {
return this.revision;
}
public async configure(config: OneTalkExtensionConfig | null): Promise<boolean> {
const alreadyUnconfigured =
config === null && this.currentConfig === null && this.active === null;
@@ -116,6 +141,8 @@ export class OneTalkConfiguredSyncSession {
this.active = null;
this.currentConfig = null;
previous?.engine.dispose();
previous?.profile?.dispose();
previous?.disposeProfileStatus();
previous?.bright.disconnect();
this.onStatusChange();
@@ -133,6 +160,7 @@ export class OneTalkConfiguredSyncSession {
if (currentRevision !== this.revision) return;
this.onStatusChange();
};
let profile: OneTalkContactProfileCoordinator | undefined;
const bright = this.createBrightClient({
url: config.brightWebSocketUrl,
scope: pluginScope,
@@ -144,6 +172,7 @@ export class OneTalkConfiguredSyncSession {
: { createRequestId: this.createRequestId }),
onStatusChange: notifyCurrentStatus,
onFrame: (frame: OneTalkFrame) => {
profile?.handleFrame(frame);
if (frame.type !== "send.command") return;
if (typeof frame.sendRequestId !== "string" || frame.sendRequestId.length === 0) {
return;
@@ -192,6 +221,29 @@ export class OneTalkConfiguredSyncSession {
onError: reportCurrentError,
onDiagnostic: this.onBrightDiagnostic,
});
const nextProfileStore = this.createProfileStore
? (this.profileStore ?? (this.profileStore = this.createProfileStore()))
: undefined;
if (nextProfileStore) {
profile = createOneTalkContactProfileCoordinator({
scope: pluginScope,
store: nextProfileStore,
bright,
...(this.now === undefined ? {} : { now: this.now }),
...(this.createRequestId === undefined
? {}
: { createRequestId: this.createRequestId }),
onError: reportCurrentError,
onDiagnostic: this.onProfileDiagnostic,
});
}
const activeProfile = profile;
const unsubscribeProfileStatus = activeProfile
? bright.subscribeStatus?.((state) => {
if (currentRevision !== this.revision) return;
activeProfile.handleStatus(state);
})
: undefined;
const nextStore = this.store ?? (this.store = this.createStore());
const engine = createOneTalkSyncEngine({
scope: pluginScope,
@@ -214,10 +266,17 @@ export class OneTalkConfiguredSyncSession {
if (currentRevision !== this.revision) {
engine.dispose();
bright.disconnect();
profile?.dispose();
unsubscribeProfileStatus?.();
return false;
}
this.currentConfig = config;
this.active = { bright, engine };
this.active = {
bright,
engine,
...(activeProfile === undefined ? {} : { profile: activeProfile }),
disposeProfileStatus: unsubscribeProfileStatus ?? (() => undefined),
};
return true;
}
@@ -0,0 +1,323 @@
// 管理联系人资料的 durable-first ledger、批量发送和 ACK fence
import {
createOneTalkContactProfileObservedFrame,
isOneTalkContactProfile,
ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE,
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
type OneTalkContactProfile,
type OneTalkFrame,
type OneTalkPluginScope,
} from "@trade-message-center/onetalk-contract";
import type { OneTalkBrightClient, OneTalkBrightClientState } from "./bright-client.ts";
import type { OneTalkContactProfileLedgerRecord, OneTalkContactProfileStore } from "./storage.ts";
export type OneTalkContactProfileDiagnostic = {
event: "profile_observed" | "profile_send" | "profile_ack" | "profile_snapshot";
status?:
| "pending"
| "skipped"
| "sent"
| "delivered"
| "stale"
| "invalid"
| "failed"
| "rejected";
code?:
| "ledger_failed"
| "bright_offline"
| "invalid_profile"
| "ack_mismatch"
| "snapshot_unavailable"
| "snapshot_failed";
requestId?: string;
profileCount?: number;
fieldNames?: readonly string[];
};
export type OneTalkContactProfileCoordinator = {
getChannelAccountId: () => string;
observe: (profiles: OneTalkContactProfile[]) => Promise<void>;
handleFrame: (frame: OneTalkFrame) => void;
handleStatus: (state: OneTalkBrightClientState) => void;
handlePageReady: () => Promise<void>;
handlePageDisconnected: () => void;
dispose: () => void;
};
type RequestEntry = {
key: string;
aliId: string;
fingerprint: string;
};
const PROFILE_FIELD_NAMES = [
"conversationId",
"aliId",
"accountId",
"loginId",
"name",
"companyName",
"countryCode",
"currentTimeZone",
"serviceType",
"observedAtMs",
"profileFingerprint",
"observationStatus",
] as const;
const emit = (
callback: ((event: OneTalkContactProfileDiagnostic) => void) | undefined,
event: OneTalkContactProfileDiagnostic,
): void => {
try {
callback?.(event);
} catch {
// 诊断不能改变 ledger 或连接生命周期。
}
};
const byteLength = (frame: OneTalkFrame): number => {
try {
return new TextEncoder().encode(JSON.stringify(frame)).byteLength;
} catch {
return Number.POSITIVE_INFINITY;
}
};
const chunksFor = (
records: OneTalkContactProfileLedgerRecord[],
): OneTalkContactProfileLedgerRecord[][] => {
const chunks: OneTalkContactProfileLedgerRecord[][] = [];
let chunk: OneTalkContactProfileLedgerRecord[] = [];
for (const record of records) {
if (!record.pending) continue;
const candidate = [...chunk, record];
const frame = createOneTalkContactProfileObservedFrame(
{
connectionType: "plugin",
requestId: "size-check",
scope: { channelAccountId: record.channelAccountId, deviceId: "size-check" },
},
candidate.map((item) => item.pending!.profile),
);
if (
chunk.length > 0 &&
(candidate.length > ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE ||
byteLength(frame) > ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES)
) {
chunks.push(chunk);
chunk = [record];
continue;
}
chunk = candidate;
}
if (chunk.length > 0) chunks.push(chunk);
return chunks;
};
const hasEntry = (
requests: Map<string, RequestEntry[]>,
record: OneTalkContactProfileLedgerRecord,
): boolean => {
if (!record.pending) return false;
for (const entries of requests.values()) {
if (
entries.some(
(entry) =>
entry.key === record.key && entry.fingerprint === record.pending!.fingerprint,
)
)
return true;
}
return false;
};
/** 创建独立联系人资料发送协调器;消息 SyncEngine 不参与该状态机。 */
export const createOneTalkContactProfileCoordinator = (options: {
scope: OneTalkPluginScope;
store: OneTalkContactProfileStore;
bright: OneTalkBrightClient;
now?: () => number;
createRequestId?: (kind: string) => string;
onError?: (error: unknown) => void;
onDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
}): OneTalkContactProfileCoordinator => {
const now = options.now ?? Date.now;
let requestSequence = 0;
const createRequestId =
options.createRequestId ?? ((kind: string) => `profile-${kind}-${++requestSequence}`);
const requests = new Map<string, RequestEntry[]>();
let disposed = false;
let flushing: Promise<void> | null = null;
let followUpFlushRequested = false;
const reportError = (error: unknown): void => {
try {
options.onError?.(error);
} catch {
// Error reporting cannot change profile state.
}
};
const flush = async (): Promise<void> => {
if (disposed || !options.bright.isOnline()) {
if (!disposed)
emit(options.onDiagnostic, { event: "profile_send", code: "bright_offline" });
return;
}
if (flushing) {
followUpFlushRequested = true;
return flushing;
}
flushing = (async () => {
const pending = (
await options.store.listPendingProfiles(options.scope.channelAccountId)
).filter((record) => !hasEntry(requests, record));
for (const chunk of chunksFor(pending)) {
if (disposed || !options.bright.isOnline()) return;
const profiles = chunk.flatMap((record) =>
record.pending ? [record.pending.profile] : [],
);
const requestId = options.bright.sendContactProfiles({
profiles,
requestId: createRequestId("batch"),
});
if (!requestId) return;
requests.set(
requestId,
chunk.flatMap((record) =>
record.pending
? [
{
key: record.key,
aliId: record.aliId,
fingerprint: record.pending.fingerprint,
},
]
: [],
),
);
emit(options.onDiagnostic, {
event: "profile_send",
status: "sent",
requestId,
profileCount: profiles.length,
fieldNames: PROFILE_FIELD_NAMES,
});
}
})()
.catch((error) => {
reportError(error);
emit(options.onDiagnostic, { event: "profile_send", code: "ledger_failed" });
})
.finally(() => {
flushing = null;
if (followUpFlushRequested && !disposed && options.bright.isOnline()) {
followUpFlushRequested = false;
void flush();
}
});
return flushing;
};
const observe = async (profiles: OneTalkContactProfile[]): Promise<void> => {
if (disposed) return;
for (const profile of profiles) {
if (!isOneTalkContactProfile(profile)) {
emit(options.onDiagnostic, {
event: "profile_observed",
status: "invalid",
code: "invalid_profile",
});
continue;
}
const record = await options.store.putPendingProfile(
options.scope.channelAccountId,
profile,
);
emit(options.onDiagnostic, {
event: "profile_observed",
status:
record.pending?.fingerprint === profile.profileFingerprint
? "pending"
: "skipped",
profileCount: 1,
fieldNames: PROFILE_FIELD_NAMES,
});
}
await flush();
};
const handleFrame = (frame: OneTalkFrame): void => {
if (disposed || frame.type !== "contact.profile.ack") return;
const entries = requests.get(frame.requestId);
if (!entries) return;
requests.delete(frame.requestId);
if (frame.payload.profileCount !== entries.length) {
emit(options.onDiagnostic, {
event: "profile_ack",
status: "invalid",
code: "ack_mismatch",
requestId: frame.requestId,
profileCount: frame.payload.profileCount,
});
return;
}
void Promise.all(
entries.map((entry) =>
options.store.markProfileUploaded({
channelAccountId: options.scope.channelAccountId,
aliId: entry.aliId,
fingerprint: entry.fingerprint,
uploadedAt: now(),
}),
),
)
.then((results) => {
const delivered = results.filter(Boolean).length;
emit(options.onDiagnostic, {
event: "profile_ack",
status: delivered === entries.length ? "delivered" : "stale",
requestId: frame.requestId,
profileCount: delivered,
fieldNames: PROFILE_FIELD_NAMES,
});
})
.catch((error) => {
reportError(error);
emit(options.onDiagnostic, {
event: "profile_ack",
status: "invalid",
code: "ledger_failed",
requestId: frame.requestId,
});
});
};
const handleStatus = (state: OneTalkBrightClientState): void => {
if (state.status !== "authenticated") {
requests.clear();
followUpFlushRequested = false;
return;
}
void flush();
};
return {
getChannelAccountId: () => options.scope.channelAccountId,
observe,
handleFrame,
handleStatus,
handlePageReady: flush,
handlePageDisconnected: () => {
requests.clear();
followUpFlushRequested = false;
},
dispose: () => {
disposed = true;
requests.clear();
followUpFlushRequested = false;
},
};
};
@@ -6,6 +6,10 @@ import {
type OneTalkServiceWorkerRuntimeOptions,
} from "./runtime.ts";
import type { OneTalkSyncEngine } from "./sync-engine.ts";
import type {
OneTalkContactProfileCoordinator,
OneTalkContactProfileDiagnostic,
} from "./contact-profile-coordinator.ts";
export type OneTalkPageIdentity = {
channelAccountId: string;
@@ -14,15 +18,128 @@ export type OneTalkPageIdentity = {
type OneTalkPageRuntimeHostOptions = {
getActiveEngine: () => OneTalkSyncEngine | null;
getActiveProfileCoordinator?: () => OneTalkContactProfileCoordinator | null;
getActiveChannelAccountId?: () => string | null;
getConfigurationEpoch?: () => number;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
onError: (error: unknown) => void;
};
type PageLifecycleToken = {
configurationEpoch: number;
connectionEpoch: number;
pageEpoch: number;
};
const diagnosticForProfileSnapshot = (
status: "failed" | "rejected",
code: "snapshot_unavailable" | "snapshot_failed",
): OneTalkContactProfileDiagnostic => ({
event: "profile_snapshot",
status,
code,
});
/** 固定页面 runtime 并协调当前页面身份。 */
export class OneTalkPageRuntimeHost {
public readonly runtime: OneTalkServiceWorkerRuntime;
private readonly options: OneTalkPageRuntimeHostOptions;
private lastPageIdentity: OneTalkPageIdentity | null = null;
private profileSnapshotRequestSequence = 0;
private connectionEpoch = 0;
private pageEpoch = 0;
private configurationEpoch(): number {
return this.options.getConfigurationEpoch?.() ?? 0;
}
private isCurrentToken(
token: PageLifecycleToken,
identity: OneTalkPageIdentity,
engine: OneTalkSyncEngine | null,
profile: OneTalkContactProfileCoordinator | null,
): boolean {
return (
token.configurationEpoch === this.configurationEpoch() &&
token.connectionEpoch === this.connectionEpoch &&
token.pageEpoch === this.pageEpoch &&
this.lastPageIdentity?.channelAccountId === identity.channelAccountId &&
this.lastPageIdentity?.conversationId === identity.conversationId &&
this.options.getActiveEngine() === engine &&
(this.options.getActiveProfileCoordinator?.() ?? null) === profile
);
}
private emitDiagnostic(event: OneTalkContactProfileDiagnostic): void {
try {
this.options.onProfileDiagnostic?.(event);
} catch {
// 页面诊断不能改变同步生命周期。
}
}
private requestProfileSnapshot(
channelAccountId: string,
token: PageLifecycleToken,
identity: OneTalkPageIdentity,
engine: OneTalkSyncEngine | null,
profile: OneTalkContactProfileCoordinator | null,
): void {
if (!this.isCurrentToken(token, identity, engine, profile)) return;
void this.runtime
.routePageCommand({
channelAccountId,
requestId: `profile-snapshot-${++this.profileSnapshotRequestSequence}`,
command: { action: "onetalk.contact.snapshot" },
})
.then((result) => {
if (!this.isCurrentToken(token, identity, engine, profile)) return;
if (
result.status === "rejected_before_send" ||
result.status === "delivery_unknown"
) {
this.emitDiagnostic(
diagnosticForProfileSnapshot("rejected", "snapshot_unavailable"),
);
}
})
.catch(() => {
if (this.isCurrentToken(token, identity, engine, profile)) {
this.emitDiagnostic(diagnosticForProfileSnapshot("failed", "snapshot_failed"));
}
});
}
private async handlePageIdentity(
channelAccountId: string,
conversationId: string | undefined,
): Promise<void> {
const identity = {
channelAccountId,
...(conversationId === undefined ? {} : { conversationId }),
} satisfies OneTalkPageIdentity;
this.lastPageIdentity = identity;
const token = {
configurationEpoch: this.configurationEpoch(),
connectionEpoch: this.connectionEpoch,
pageEpoch: ++this.pageEpoch,
} satisfies PageLifecycleToken;
const engine = this.options.getActiveEngine();
const profile = this.options.getActiveProfileCoordinator?.() ?? null;
if (
this.options.getActiveChannelAccountId &&
this.options.getActiveChannelAccountId() !== channelAccountId
)
return;
this.requestProfileSnapshot(channelAccountId, token, identity, engine, profile);
await engine?.handlePageReady(channelAccountId, conversationId);
if (!this.isCurrentToken(token, identity, engine, profile)) return;
await profile?.handlePageReady();
if (!this.isCurrentToken(token, identity, engine, profile)) return;
}
public constructor(options: OneTalkPageRuntimeHostOptions) {
this.options = options;
const pageRuntimeOptions = {
persistPageObservation: async (
message: Parameters<
@@ -33,15 +150,36 @@ export class OneTalkPageRuntimeHost {
>[1],
channelAccountId: string,
): Promise<void> => {
const activeChannelAccountId = options.getActiveChannelAccountId?.();
if (
activeChannelAccountId !== undefined &&
activeChannelAccountId !== channelAccountId
)
return;
const engine = options.getActiveEngine();
if (!engine) return;
await engine.handlePageObservation(message, channelAccountId);
void sender;
},
persistPageProfileObservation: async (
message,
sender,
channelAccountId,
): Promise<void> => {
const coordinator = options.getActiveProfileCoordinator?.();
if (!coordinator) return;
if (channelAccountId !== coordinatorScope(coordinator)) return;
await coordinator.observe(message.profiles);
void sender;
},
onPageMessage: () => undefined,
onPageProfileMessage: () => undefined,
onPageDisconnect: () => {
this.connectionEpoch += 1;
this.pageEpoch += 1;
this.lastPageIdentity = null;
options.getActiveEngine()?.handlePageDisconnected();
options.getActiveProfileCoordinator?.()?.handlePageDisconnected();
},
onPageIdentity: async (
_sender: Parameters<
@@ -49,13 +187,7 @@ export class OneTalkPageRuntimeHost {
>[0],
channelAccountId: string,
conversationId?: string,
): Promise<void> => {
this.lastPageIdentity = {
channelAccountId,
...(conversationId === undefined ? {} : { conversationId }),
};
await options.getActiveEngine()?.handlePageReady(channelAccountId, conversationId);
},
): Promise<void> => this.handlePageIdentity(channelAccountId, conversationId),
onError: options.onError,
} satisfies OneTalkServiceWorkerRuntimeOptions;
this.runtime = createOneTalkServiceWorkerRuntime(pageRuntimeOptions);
@@ -66,9 +198,32 @@ export class OneTalkPageRuntimeHost {
return { ...this.lastPageIdentity };
}
public async replayTo(engine: OneTalkSyncEngine): Promise<void> {
public async replayTo(
engine: OneTalkSyncEngine,
profileCoordinator?: OneTalkContactProfileCoordinator | null,
configurationEpoch = this.configurationEpoch(),
): Promise<void> {
const identity = this.getLastPageIdentity();
if (identity === null) return;
const profile = profileCoordinator ?? null;
const token = {
configurationEpoch,
connectionEpoch: this.connectionEpoch,
pageEpoch: this.pageEpoch,
} satisfies PageLifecycleToken;
if (
!this.isCurrentToken(token, identity, engine, profile) ||
(this.options.getActiveChannelAccountId?.() ?? identity.channelAccountId) !==
identity.channelAccountId
)
return;
this.requestProfileSnapshot(identity.channelAccountId, token, identity, engine, profile);
await engine.handlePageReady(identity.channelAccountId, identity.conversationId);
if (!this.isCurrentToken(token, identity, engine, profile)) return;
await profile?.handlePageReady();
}
}
const coordinatorScope = (coordinator: OneTalkContactProfileCoordinator): string => {
return coordinator.getChannelAccountId();
};
@@ -9,6 +9,7 @@ import {
type OneTalkPageCommandResultMessage,
type OneTalkPageMessage,
type OneTalkPageObservedMessage,
type OneTalkPageProfileObservedMessage,
type PageCommand,
type PageCommandResult,
} from "../page-bridge/model.ts";
@@ -56,6 +57,17 @@ export type OneTalkPageObservationPersister = (
channelAccountId: string,
) => void | Promise<void>;
export type OneTalkPageProfileObservationHandler = (
message: OneTalkPageProfileObservedMessage,
sender: OneTalkServiceWorkerSender,
) => void | Promise<void>;
export type OneTalkPageProfileObservationPersister = (
message: OneTalkPageProfileObservedMessage,
sender: OneTalkServiceWorkerSender,
channelAccountId: string,
) => void | Promise<void>;
export type OneTalkPageIdentityHandler = (
sender: OneTalkServiceWorkerSender,
channelAccountId: string,
@@ -88,7 +100,9 @@ export type OneTalkPageDiagnostic = {
export type OneTalkServiceWorkerRuntimeOptions = {
onPageMessage: OneTalkPageMessageHandler;
onPageProfileMessage?: OneTalkPageProfileObservationHandler;
persistPageObservation?: OneTalkPageObservationPersister;
persistPageProfileObservation?: OneTalkPageProfileObservationPersister;
onPageIdentity?: OneTalkPageIdentityHandler;
onPageDisconnect?: () => void;
onError?: (error: unknown) => void;
@@ -232,9 +246,9 @@ const registerPageIdentity = (
conversationId: string | undefined,
conversationSelection: "none" | "multiple" | undefined,
onDiagnostic?: (event: OneTalkPageDiagnostic) => void,
): void => {
): boolean => {
if (hasSamePageIdentity(connection, channelAccountId, conversationId, conversationSelection))
return;
return false;
settlePendingCommands(connection, onDiagnostic);
emitDiagnostic(onDiagnostic, {
event: "page_hello",
@@ -246,6 +260,7 @@ const registerPageIdentity = (
connection.channelAccountId = channelAccountId;
connection.conversationId = conversationId;
connection.conversationSelection = conversationSelection;
return true;
};
const reportRuntimeError = (
@@ -293,6 +308,43 @@ const dispatchPageObservation = (
}
};
const dispatchPageProfileObservation = (
options: OneTalkServiceWorkerRuntimeOptions,
connection: PageConnection,
message: OneTalkPageProfileObservedMessage,
): void => {
const channelAccountId = connection.channelAccountId;
if (!channelAccountId || message.channelAccountId !== channelAccountId) {
emitDiagnostic(options.onDiagnostic, {
event: "page_hello",
direction: "inbound",
frameType: "onetalk.page.profile-observed",
status: "rejected",
code: "profile_identity_mismatch",
});
return;
}
const onProfileMessage = options.onPageProfileMessage;
if (!onProfileMessage) return;
try {
const persist = options.persistPageProfileObservation;
const notify = (): void | Promise<void> => onProfileMessage(message, connection.sender);
if (!persist) {
void Promise.resolve(notify()).catch((error) => {
reportRuntimeError(options.onError, error);
});
return;
}
void Promise.resolve(persist(message, connection.sender, channelAccountId))
.then(notify)
.catch((error) => {
reportRuntimeError(options.onError, error);
});
} catch (error) {
reportRuntimeError(options.onError, error);
}
};
const resolvePageCommandResult = (
connection: PageConnection,
message: OneTalkPageCommandResultMessage,
@@ -334,14 +386,14 @@ const createPageMessageHandler = (
switch (message.type) {
case "onetalk.page.hello":
registerPageIdentity(
const identityChanged = registerPageIdentity(
connection,
message.channelAccountId,
message.conversationId,
message.conversationSelection,
options.onDiagnostic,
);
if (options.onPageIdentity) {
if (identityChanged && options.onPageIdentity) {
void Promise.resolve(
options.onPageIdentity(
connection.sender,
@@ -357,6 +409,9 @@ const createPageMessageHandler = (
if (!connection.channelAccountId) return;
dispatchPageObservation(options, connection, message);
return;
case "onetalk.page.profile-observed":
dispatchPageProfileObservation(options, connection, message);
return;
case "onetalk.page.command-result":
resolvePageCommandResult(connection, message, options.onDiagnostic);
return;
@@ -392,7 +447,7 @@ const findAccountPages = (
};
const isAccountLevelCommand = (command: PageCommand): boolean => {
return command.action === "onetalk.sync";
return command.action === "onetalk.sync" || command.action === "onetalk.contact.snapshot";
};
const isSendCommand = (command: PageCommand): boolean => command.action === "onetalk.send";
@@ -2,6 +2,7 @@
import type {
OneTalkJsonValue,
OneTalkContactProfile,
OneTalkObservedMessage,
OneTalkObservationSource,
OneTalkSyncAnomalyCode,
@@ -12,12 +13,13 @@ import type {
import type { ObservedOneTalkMessage } from "../main-page/message-observer/model.ts";
export const ONE_TALK_MESSAGE_DATABASE_NAME = "trade-message-center";
export const ONE_TALK_SYNC_DATABASE_VERSION = 3;
export const ONE_TALK_SYNC_DATABASE_VERSION = 4;
export const ONE_TALK_MESSAGE_DATABASE_VERSION = ONE_TALK_SYNC_DATABASE_VERSION;
export const ONE_TALK_MESSAGE_STORE_NAME = "onetalk_messages";
export const ONE_TALK_CHECKPOINT_STORE_NAME = "onetalk_sync_checkpoints";
export const ONE_TALK_CANDIDATE_STORE_NAME = "onetalk_sync_candidates";
export const ONE_TALK_ANOMALY_STORE_NAME = "onetalk_sync_anomalies";
export const ONE_TALK_CONTACT_PROFILE_STORE_NAME = "onetalk_contact_profiles";
export type StoredOneTalkMessage = ObservedOneTalkMessage & {
key: string;
@@ -84,6 +86,22 @@ export type OneTalkSyncAnomaly = {
lastObservedAt: number;
};
export type OneTalkContactProfilePending = {
profile: OneTalkContactProfile;
fingerprint: string;
observedAtMs: number;
};
export type OneTalkContactProfileLedgerRecord = {
key: string;
channelAccountId: string;
aliId: string;
lastUploadedFingerprint: string | null;
pending?: OneTalkContactProfilePending;
updatedAt: number;
lastUploadedAt?: number;
};
export type OneTalkMessageStore = {
putBatch: (channelAccountId: string, batch: ObservedOneTalkMessage[]) => Promise<void>;
};
@@ -133,6 +151,26 @@ export type OneTalkSyncStore = OneTalkMessageStore & {
) => Promise<OneTalkSyncAnomaly[]>;
};
export type OneTalkContactProfileStore = {
getProfile: (
channelAccountId: string,
aliId: string,
) => Promise<OneTalkContactProfileLedgerRecord | null>;
listPendingProfiles: (
channelAccountId?: string,
) => Promise<OneTalkContactProfileLedgerRecord[]>;
putPendingProfile: (
channelAccountId: string,
profile: OneTalkContactProfile,
) => Promise<OneTalkContactProfileLedgerRecord>;
markProfileUploaded: (input: {
channelAccountId: string;
aliId: string;
fingerprint: string;
uploadedAt: number;
}) => Promise<boolean>;
};
const messageKey = (channelAccountId: string, message: ObservedOneTalkMessage): string => {
return JSON.stringify([channelAccountId, message.conversationId, message.messageId]);
};
@@ -149,6 +187,10 @@ const candidateKey = (
return JSON.stringify([channelAccountId, conversationId, messageId]);
};
export const contactProfileKey = (channelAccountId: string, aliId: string): string => {
return JSON.stringify([channelAccountId, aliId]);
};
const isNonEmptyString = (value: unknown): value is string => {
return typeof value === "string" && value.trim().length > 0;
};
@@ -246,6 +288,7 @@ const ensureSyncStores = (database: IDBDatabase): void => {
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
];
for (const storeName of stores) {
if (!database.objectStoreNames.contains(storeName)) {
@@ -726,3 +769,131 @@ export const createOneTalkSyncStore = (
listAnomalies,
};
};
const profileRecordFor = (
channelAccountId: string,
profile: OneTalkContactProfile,
updatedAt: number,
existing?: OneTalkContactProfileLedgerRecord,
): OneTalkContactProfileLedgerRecord => ({
key: contactProfileKey(channelAccountId, profile.aliId),
channelAccountId,
aliId: profile.aliId,
lastUploadedFingerprint: existing?.lastUploadedFingerprint ?? null,
pending: {
profile: { ...profile },
fingerprint: profile.profileFingerprint,
observedAtMs: profile.observedAtMs,
},
updatedAt,
...(existing?.lastUploadedAt === undefined ? {} : { lastUploadedAt: existing.lastUploadedAt }),
});
/** 创建独立联系人资料 ledger;不会读写消息、candidate、checkpoint 或 anomaly store。 */
export const createOneTalkContactProfileStore = (
factory: IDBFactory = indexedDB,
now: () => number = Date.now,
): OneTalkContactProfileStore => {
let databasePromise: Promise<IDBDatabase> | null = null;
const getDatabase = (): Promise<IDBDatabase> => {
databasePromise ??= openSyncDatabase(factory).catch((error: unknown) => {
databasePromise = null;
throw error;
});
return databasePromise;
};
const getProfile = async (
channelAccountId: string,
aliId: string,
): Promise<OneTalkContactProfileLedgerRecord | null> => {
const database = await getDatabase();
return (
(await readOne<OneTalkContactProfileLedgerRecord>(
database,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
contactProfileKey(channelAccountId, aliId),
)) ?? null
);
};
const listPendingProfiles = async (
channelAccountId?: string,
): Promise<OneTalkContactProfileLedgerRecord[]> => {
const database = await getDatabase();
const records = await readAll<OneTalkContactProfileLedgerRecord>(
database,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
);
return records.filter(
(record) =>
record.pending !== undefined &&
(channelAccountId === undefined || record.channelAccountId === channelAccountId),
);
};
const putPendingProfile = async (
channelAccountId: string,
profile: OneTalkContactProfile,
): Promise<OneTalkContactProfileLedgerRecord> => {
const database = await getDatabase();
const transaction = database.transaction(ONE_TALK_CONTACT_PROFILE_STORE_NAME, "readwrite");
const completion = transactionResult(transaction);
const store = transaction.objectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME);
const request = store.get(contactProfileKey(channelAccountId, profile.aliId));
let result: OneTalkContactProfileLedgerRecord | undefined;
request.onsuccess = () => {
const existing = request.result as OneTalkContactProfileLedgerRecord | undefined;
if (
existing &&
existing.pending === undefined &&
existing.lastUploadedFingerprint === profile.profileFingerprint
) {
result = { ...existing };
return;
}
if (
existing?.pending?.fingerprint === profile.profileFingerprint &&
existing.pending.profile.conversationId === profile.conversationId
) {
result = { ...existing, pending: { ...existing.pending } };
return;
}
result = profileRecordFor(channelAccountId, profile, now(), existing);
store.put(result);
};
await completion;
if (!result) throw new Error("IndexedDB profile ledger write failed");
return result;
};
const markProfileUploaded = async (input: {
channelAccountId: string;
aliId: string;
fingerprint: string;
uploadedAt: number;
}): Promise<boolean> => {
const database = await getDatabase();
const transaction = database.transaction(ONE_TALK_CONTACT_PROFILE_STORE_NAME, "readwrite");
const completion = transactionResult(transaction);
const store = transaction.objectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME);
const request = store.get(contactProfileKey(input.channelAccountId, input.aliId));
let shouldMark = false;
request.onsuccess = () => {
const existing = request.result as OneTalkContactProfileLedgerRecord | undefined;
if (existing?.pending?.fingerprint !== input.fingerprint) return;
const { pending: _pending, ...withoutPending } = existing;
store.put({
...withoutPending,
lastUploadedFingerprint: input.fingerprint,
updatedAt: input.uploadedAt,
lastUploadedAt: input.uploadedAt,
});
shouldMark = true;
};
await completion;
return shouldMark;
};
return { getProfile, listPendingProfiles, putPendingProfile, markProfileUploaded };
};
@@ -17,6 +17,8 @@ import { OneTalkPageRuntimeHost } from "./page-runtime-host.ts";
import { OneTalkConfiguredSyncSession } from "./configured-sync-session.ts";
import type { OneTalkServiceWorkerRuntime } from "./runtime.ts";
import type { OneTalkSyncStore } from "./storage.ts";
import type { OneTalkContactProfileStore } from "./storage.ts";
import type { OneTalkContactProfileDiagnostic } from "./contact-profile-coordinator.ts";
import type {
OneTalkSyncEngineDiagnostic,
OneTalkSyncEngine,
@@ -36,12 +38,14 @@ export type OneTalkServiceWorkerSyncSnapshot = {
export type OneTalkServiceWorkerSyncControllerOptions = {
createBrightClient?: (options: OneTalkBrightClientOptions) => OneTalkBrightClient;
createStore?: () => OneTalkSyncStore;
createProfileStore?: () => OneTalkContactProfileStore;
onStatusChange?: (snapshot: OneTalkServiceWorkerSyncSnapshot) => void;
onEngineStatusChange?: (status: OneTalkSyncEngineStatus) => void;
onError?: (error: unknown) => void;
onBrightDiagnostic?: (event: OneTalkBrightDiagnostic) => void;
onPageDiagnostic?: (event: OneTalkPageDiagnostic) => void;
onEngineDiagnostic?: (event: OneTalkSyncEngineDiagnostic) => void;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
now?: () => number;
createRequestId?: (kind: string) => string;
};
@@ -123,11 +127,16 @@ export const createOneTalkServiceWorkerSyncController = (
};
const pageHost = new OneTalkPageRuntimeHost({
getActiveEngine: () => session?.getActive()?.engine ?? null,
getActiveProfileCoordinator: () => session?.getActive()?.profile ?? null,
getActiveChannelAccountId: () => session?.getConfig()?.channelAccountId ?? null,
getConfigurationEpoch: () => session?.getRevision() ?? 0,
onProfileDiagnostic: options.onProfileDiagnostic,
onError: reportError,
});
session = new OneTalkConfiguredSyncSession({
createBrightClient: options.createBrightClient,
createStore: options.createStore,
createProfileStore: options.createProfileStore,
pageRuntime: {
routePageCommand: (route) => pageHost.runtime.routePageCommand(route),
},
@@ -143,6 +152,7 @@ export const createOneTalkServiceWorkerSyncController = (
onBrightDiagnostic: options.onBrightDiagnostic,
onPageDiagnostic: options.onPageDiagnostic,
onEngineDiagnostic: options.onEngineDiagnostic,
onProfileDiagnostic: options.onProfileDiagnostic,
now: options.now,
createRequestId: options.createRequestId,
});
@@ -152,7 +162,9 @@ export const createOneTalkServiceWorkerSyncController = (
if (!changed) return;
const active = session!.getActive();
if (!active) return;
await pageHost.replayTo(active.engine);
const configurationEpoch = session!.getRevision();
await pageHost.replayTo(active.engine, active.profile, configurationEpoch);
if (session!.getRevision() !== configurationEpoch) return;
notifyStatus();
session!.connectCurrent();
};
@@ -3,11 +3,8 @@
import type { OneTalkPluginScope } from "@trade-message-center/onetalk-contract";
import type { OneTalkBrightClient } from "./bright-client.ts";
import {
createOneTalkServiceWorkerRuntime,
type OneTalkPageDiagnostic,
type OneTalkServiceWorkerRuntime,
} from "./runtime.ts";
import { type OneTalkPageDiagnostic, type OneTalkServiceWorkerRuntime } from "./runtime.ts";
import { OneTalkPageRuntimeHost } from "./page-runtime-host.ts";
import {
createOneTalkSyncEngine,
type OneTalkSyncEngine,
@@ -15,6 +12,12 @@ import {
type OneTalkSyncEngineOptions,
} from "./sync-engine.ts";
import type { OneTalkSyncStore } from "./storage.ts";
import type { OneTalkContactProfileStore } from "./storage.ts";
import type { OneTalkContactProfileDiagnostic } from "./contact-profile-coordinator.ts";
import {
createOneTalkContactProfileCoordinator,
type OneTalkContactProfileCoordinator,
} from "./contact-profile-coordinator.ts";
export type OneTalkServiceWorkerSyncRuntimeOptions = Pick<
OneTalkSyncEngineOptions,
@@ -23,37 +26,53 @@ export type OneTalkServiceWorkerSyncRuntimeOptions = Pick<
scope: OneTalkPluginScope;
bright: OneTalkBrightClient;
store: OneTalkSyncStore;
profileStore?: OneTalkContactProfileStore;
onPageDiagnostic?: (event: OneTalkPageDiagnostic) => void;
onEngineDiagnostic?: (event: OneTalkSyncEngineDiagnostic) => void;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
};
export type OneTalkServiceWorkerSyncRuntime = {
runtime: OneTalkServiceWorkerRuntime;
engine: OneTalkSyncEngine;
profile?: OneTalkContactProfileCoordinator;
};
/** 创建页面观察先落账本、再进入 Bright 队列的 Service Worker 组合。 */
export const createOneTalkServiceWorkerSyncRuntime = (
options: OneTalkServiceWorkerSyncRuntimeOptions,
): OneTalkServiceWorkerSyncRuntime => {
let runtime: OneTalkServiceWorkerRuntime;
let pageHost: OneTalkPageRuntimeHost;
const pageRuntime: Pick<OneTalkServiceWorkerRuntime, "routePageCommand"> = {
routePageCommand: (route) => runtime.routePageCommand(route),
routePageCommand: (route) => pageHost.runtime.routePageCommand(route),
};
const engine = createOneTalkSyncEngine({
...options,
pageRuntime,
});
runtime = createOneTalkServiceWorkerRuntime({
persistPageObservation: (message, _sender, channelAccountId) =>
engine.handlePageObservation(message, channelAccountId).then(() => undefined),
onPageMessage: () => undefined,
onPageIdentity: (_sender, channelAccountId, conversationId) =>
engine.handlePageReady(channelAccountId, conversationId),
onError: options.onError,
onDiagnostic: options.onPageDiagnostic,
const profile = options.profileStore
? createOneTalkContactProfileCoordinator({
scope: options.scope,
store: options.profileStore,
bright: options.bright,
...(options.now === undefined ? {} : { now: options.now }),
...(options.createRequestId === undefined
? {}
: { createRequestId: options.createRequestId }),
onError: options.onError,
onDiagnostic: options.onProfileDiagnostic,
})
: undefined;
options.bright.subscribe((frame) => profile?.handleFrame(frame));
options.bright.subscribeStatus?.((state) => profile?.handleStatus(state));
pageHost = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => profile ?? null,
getActiveChannelAccountId: () => options.scope.channelAccountId,
onProfileDiagnostic: options.onProfileDiagnostic,
onError: options.onError ?? (() => undefined),
});
return { runtime, engine };
return { runtime: pageHost.runtime, engine, ...(profile === undefined ? {} : { profile }) };
};
export {
@@ -25,6 +25,7 @@ import { BRIGHT_WEBSOCKET_URL, ENABLE_ONE_TALK_DIAGNOSTICS } from "./onetalk/bui
import type { OneTalkPageDiagnostic } from "./onetalk/service-worker/runtime.ts";
import type { OneTalkSyncEngineDiagnostic } from "./onetalk/service-worker/sync-engine.ts";
import type { OneTalkBrightDiagnostic } from "./onetalk/service-worker/bright-client.ts";
import type { OneTalkContactProfileDiagnostic } from "./onetalk/service-worker/contact-profile-coordinator.ts";
declare const chrome: {
runtime: {
@@ -61,7 +62,8 @@ type OneTalkConfigMessageResponseWithEngineStatus =
type OneTalkDiagnostic =
| OneTalkBrightDiagnostic
| OneTalkPageDiagnostic
| OneTalkSyncEngineDiagnostic;
| OneTalkSyncEngineDiagnostic
| OneTalkContactProfileDiagnostic;
const logOneTalkDiagnostic = (source: string, event: OneTalkDiagnostic): void => {
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
@@ -109,6 +111,8 @@ const controller: OneTalkServiceWorkerSyncController = createOneTalkServiceWorke
onBrightDiagnostic: (event: OneTalkBrightDiagnostic) => logOneTalkDiagnostic("Bright", event),
onPageDiagnostic: (event: OneTalkPageDiagnostic) => logOneTalkDiagnostic("Page", event),
onEngineDiagnostic: (event: OneTalkSyncEngineDiagnostic) => logOneTalkDiagnostic("Sync", event),
onProfileDiagnostic: (event: OneTalkContactProfileDiagnostic) =>
logOneTalkDiagnostic("Profile", event),
onStatusChange: (snapshot) => {
broadcastStatus(snapshot);
if (!ENABLE_ONE_TALK_DIAGNOSTICS) return;
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkContactProfileCoordinator } from "../src/onetalk/service-worker/contact-profile-coordinator.ts";
const scope = { channelAccountId: "login-account-1", deviceId: "device-1" };
const profile = (name, fingerprint, aliId = "2208314000798") => ({
conversationId: "conversation-1",
aliId,
accountId: "243340382",
loginId: "hzhago",
name,
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: fingerprint,
observationStatus: "confirmed",
});
const createFixture = ({ holdFirstList = false } = {}) => {
const records = new Map();
const sent = [];
let listCount = 0;
let listStarted;
let releaseList;
const firstListStarted = new Promise((resolve) => {
listStarted = resolve;
});
const store = {
listPendingProfiles: async () => {
const snapshot = [...records.values()].filter((record) => record.pending);
listCount += 1;
if (!holdFirstList || listCount !== 1) return snapshot;
await new Promise((resolve) => {
releaseList = () => resolve(snapshot);
listStarted();
});
return snapshot;
},
putPendingProfile: async (channelAccountId, next) => {
const key = JSON.stringify([channelAccountId, next.aliId]);
const current = records.get(key);
if (
current &&
!current.pending &&
current.lastUploadedFingerprint === next.profileFingerprint
) {
return current;
}
if (current?.pending?.fingerprint === next.profileFingerprint) return current;
const updated = {
key,
channelAccountId,
aliId: next.aliId,
lastUploadedFingerprint: current?.lastUploadedFingerprint ?? null,
pending: {
profile: next,
fingerprint: next.profileFingerprint,
observedAtMs: next.observedAtMs,
},
updatedAt: 1,
};
records.set(key, updated);
return updated;
},
markProfileUploaded: async ({ channelAccountId, aliId, fingerprint, uploadedAt }) => {
const key = JSON.stringify([channelAccountId, aliId]);
const current = records.get(key);
if (current?.pending?.fingerprint !== fingerprint) return false;
records.set(key, {
...current,
pending: undefined,
lastUploadedFingerprint: fingerprint,
updatedAt: uploadedAt,
lastUploadedAt: uploadedAt,
});
return true;
},
};
const bright = {
isOnline: () => true,
sendContactProfiles: (input) => {
const requestId = `request-${sent.length + 1}`;
sent.push({ requestId, ...input });
return requestId;
},
};
const coordinator = createOneTalkContactProfileCoordinator({
scope,
store,
bright,
now: () => 1_700_000_000_100,
});
return { coordinator, records, sent, firstListStarted, releaseList: () => releaseList?.() };
};
test("writes pending before send, replaces pending profile, and ignores stale ACK", async () => {
const fixture = createFixture();
const first = profile("First", "v1-first");
const second = profile("Second", "v1-second");
await fixture.coordinator.observe([first]);
assert.equal(fixture.sent.length, 1);
await fixture.coordinator.observe([second]);
assert.equal(fixture.sent.length, 2);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "request-1",
scope,
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
fixture.records.get(JSON.stringify([scope.channelAccountId, first.aliId])).pending.profile
.name,
"Second",
);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "request-2",
scope,
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
const uploaded = fixture.records.get(JSON.stringify([scope.channelAccountId, first.aliId]));
assert.equal(uploaded.pending, undefined);
assert.equal(uploaded.lastUploadedFingerprint, "v1-second");
await fixture.coordinator.observe([second]);
assert.equal(fixture.sent.length, 2);
});
test("ignores unknown or mismatched ACKs without confirming pending data", async () => {
const fixture = createFixture();
const first = profile("First", "v1-first");
await fixture.coordinator.observe([first]);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "unknown-request",
scope,
payload: { status: "delivered", profileCount: 1 },
});
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "request-1",
scope,
payload: { status: "delivered", profileCount: 2 },
});
await new Promise((resolve) => setImmediate(resolve));
const pending = fixture.records.get(JSON.stringify([scope.channelAccountId, first.aliId]));
assert.equal(pending.pending.fingerprint, "v1-first");
fixture.coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(fixture.sent.length, 2);
});
test("clears only in-memory request correlation on Bright close and page disconnect", async () => {
const fixture = createFixture();
const first = profile("First", "v1-first");
await fixture.coordinator.observe([first]);
assert.equal(fixture.sent.length, 1);
fixture.coordinator.handleStatus({ status: "offline", permissions: [] });
fixture.coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(fixture.sent.length, 2);
fixture.coordinator.handlePageDisconnected();
fixture.coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(fixture.sent.length, 3);
assert.equal(
fixture.records.get(JSON.stringify([scope.channelAccountId, first.aliId])).pending
.fingerprint,
"v1-first",
);
});
test("schedules one bounded follow-up flush when an update races the first flush", async () => {
const fixture = createFixture({ holdFirstList: true });
const first = profile("First", "v1-first");
const second = profile("Second", "v1-second");
const firstObservation = fixture.coordinator.observe([first]);
await fixture.firstListStarted;
const secondObservation = fixture.coordinator.observe([second]);
fixture.releaseList();
await Promise.all([firstObservation, secondObservation]);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
fixture.sent.map((entry) => entry.profiles[0].profileFingerprint),
["v1-first", "v1-second"],
);
});
test("sends a chunk update after a multi-chunk flush has already started", async () => {
const fixture = createFixture({ holdFirstList: true });
const initial = Array.from({ length: 101 }, (_, index) =>
profile(`Profile ${index}`, `v1-${index}`, `ali-${index}`),
);
const updated = profile("Updated", "v2-50", "ali-50");
const firstObservation = fixture.coordinator.observe(initial);
await fixture.firstListStarted;
const updateObservation = fixture.coordinator.observe([updated]);
fixture.releaseList();
await Promise.all([firstObservation, updateObservation]);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
fixture.sent.map((entry) => entry.profiles.length),
[100, 1, 1],
);
assert.equal(fixture.sent.at(-1).profiles[0].profileFingerprint, "v2-50");
});
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkContactProfileObserver } from "../src/onetalk/main-page/contact-observer/entry.ts";
import { profileFromConversationRow } from "../src/onetalk/main-page/contact-observer/model.ts";
const row = (name = "Heena Liu") => ({
cid: "conversation-1",
aliId: 2208314000798,
accountId: 243340382,
loginId: "hzhago",
name,
companyName: "Hago",
complianceCountryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
chatToken: "secret-chat-token",
aliIdEncrypt: "secret-encrypted-id",
});
const createPage = (initial) => {
let listener;
let unsubscribed = false;
const page = {
location: { href: "https://onetalk.alibaba.com/message/weblitePWA.htm" },
currentUserAccountId: "login-account-1",
__conversationListData__: initial,
EventBus: {
on: (_event, next) => {
listener = next;
return () => {
unsubscribed = true;
listener = undefined;
};
},
},
addEventListener: (type, next) => {
if (type === "pagehide") page.pagehide = next;
},
emitSyncData: (value) => listener?.(value),
pagehide: undefined,
};
return { page, isUnsubscribed: () => unsubscribed };
};
test("constructs a whitelist profile without leaking sensitive row fields", () => {
const profile = profileFromConversationRow(row(), 1_700_000_000_000);
assert.deepEqual(profile, {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: profile.profileFingerprint,
observationStatus: "confirmed",
});
assert.equal(JSON.stringify(profile).includes("secret"), false);
});
test("emits snapshot, changed syncData, skips groups and unsubscribes on pagehide", () => {
const fixture = createPage({ first: row() });
const batches = [];
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
batches.push(profiles);
});
assert.deepEqual(batches, []);
observer.snapshot();
assert.equal(batches.length, 1);
assert.equal(batches[0][0].aliId, "2208314000798");
fixture.page.emitSyncData({ first: row() });
assert.equal(batches.length, 1);
fixture.page.emitSyncData({
first: row("Changed Name"),
group: { ...row(), cid: "group-1", isGroup: true },
incomplete: { cid: "missing-ali", name: "ignored" },
});
assert.equal(batches.length, 2);
assert.equal(batches[1][0].name, "Changed Name");
fixture.page.pagehide();
assert.equal(fixture.isUnsubscribed(), true);
fixture.page.emitSyncData({ first: row("After Hide") });
assert.equal(batches.length, 2);
});
test("requires logged-in identity for a snapshot and never uses activeAccountId", () => {
const fixture = createPage({ first: row() });
fixture.page.currentUserAccountId = undefined;
fixture.page.IcbuIM = { UserUtil: { currentUser: { accountId: "" } } };
fixture.page.location.href += "?activeAccountId=selected-contact";
const batches = [];
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
batches.push(profiles);
});
assert.deepEqual(observer.snapshot(), []);
assert.deepEqual(batches, []);
});
test("drops syncData without login identity and labels switched-account updates", () => {
const fixture = createPage({ first: row() });
const batches = [];
installOneTalkContactProfileObserver(fixture.page, (profiles, channelAccountId) => {
batches.push({ profiles, channelAccountId });
});
fixture.page.emitSyncData({ first: row("Before Snapshot") });
assert.equal(batches.length, 1);
assert.equal(batches[0].channelAccountId, "login-account-1");
fixture.page.currentUserAccountId = undefined;
fixture.page.IcbuIM = { UserUtil: { currentUser: { accountId: "" } } };
fixture.page.emitSyncData({ first: row("Logged Out") });
assert.equal(batches.length, 1);
fixture.page.currentUserAccountId = "login-account-2";
fixture.page.emitSyncData({ first: row("Before New Hello") });
assert.equal(batches.length, 2);
assert.equal(batches[1].channelAccountId, "login-account-2");
assert.equal(batches[1].profiles[0].name, "Before New Hello");
});
@@ -0,0 +1,275 @@
// 验证联系人资料 ledger 的 transaction commit fence
import assert from "node:assert/strict";
import test from "node:test";
import {
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
createOneTalkContactProfileStore,
contactProfileKey,
} from "../src/onetalk/service-worker/storage.ts";
const profile = (fingerprint = "v1-profile") => ({
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: fingerprint,
observationStatus: "confirmed",
});
class Request {
constructor(result, schedule = true) {
this.result = result;
this.error = null;
this.onsuccess = null;
this.onerror = null;
this.onupgradeneeded = null;
if (schedule) queueMicrotask(() => this.onsuccess?.());
}
}
class Store {
constructor(name) {
this.name = name;
this.records = new Map();
}
get(key) {
return new Request(this.records.get(key));
}
getAll() {
return new Request([...this.records.values()]);
}
put(record) {
this.transaction.stage(this.records, record.key, record);
}
openCursor() {
const entries = [...this.records.entries()];
const request = new Request(null, false);
let index = 0;
const advance = () => {
if (index >= entries.length) {
request.result = null;
request.onsuccess?.();
return;
}
const [key, record] = entries[index++];
request.result = {
value: structuredClone(record),
update: (updated) => this.records.set(key, structuredClone(updated)),
continue: () => queueMicrotask(advance),
};
request.onsuccess?.();
};
queueMicrotask(advance);
return request;
}
attach(transaction) {
this.transaction = transaction;
}
}
class Transaction {
constructor(database, hold) {
this.database = database;
this.hold = hold;
this.staged = [];
this.error = null;
this.oncomplete = null;
this.onerror = null;
this.onabort = null;
if (!hold) setImmediate(() => this.complete());
}
objectStore(name) {
const store = this.database.stores.get(name);
if (!store) throw new Error(`Missing object store: ${name}`);
store.attach(this);
return store;
}
stage(records, key, record) {
this.staged.push([records, key, record]);
}
complete() {
for (const [records, key, record] of this.staged) {
records.set(key, structuredClone(record));
}
this.oncomplete?.();
}
abort() {
this.error = new Error("aborted");
this.onabort?.();
}
fail() {
this.error = new Error("failed");
this.onerror?.();
}
}
class Database {
constructor() {
this.version = 0;
this.stores = new Map();
this.objectStoreNames = { contains: (name) => this.stores.has(name) };
this.nextHeldTransaction = false;
this.lastTransaction = null;
for (const name of [
"onetalk_messages",
"onetalk_sync_checkpoints",
"onetalk_sync_candidates",
"onetalk_sync_anomalies",
]) {
this.createObjectStore(name);
}
this.stores.get("onetalk_messages").records.set("legacy-message", {
key: "legacy-message",
messageId: "message-1",
loginUserId: "legacy-login",
});
this.stores.get("onetalk_sync_candidates").records.set("legacy-candidate", {
key: "legacy-candidate",
message: { messageId: "message-1", loginUserId: "legacy-login" },
loginUserId: "legacy-login",
});
}
createObjectStore(name) {
const store = new Store(name);
this.stores.set(name, store);
return store;
}
transaction() {
const transaction = new Transaction(this, this.nextHeldTransaction);
this.nextHeldTransaction = false;
this.lastTransaction = transaction;
return transaction;
}
}
class Factory {
constructor() {
this.database = new Database();
}
open(_name, version) {
const request = new Request(undefined, false);
queueMicrotask(() => {
if (this.database.version < version) {
const upgrade = new Transaction(this.database, false);
request.transaction = upgrade;
request.result = this.database;
request.onupgradeneeded?.({
oldVersion: this.database.version,
newVersion: version,
});
this.database.version = version;
}
request.onsuccess?.();
});
return request;
}
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("upgrades existing legacy stores to the profile ledger schema", async () => {
const factory = new Factory();
const store = createOneTalkContactProfileStore(factory, () => 100);
assert.equal(await store.getProfile("account-1", "missing"), null);
assert.equal(factory.database.version, 4);
assert.equal(factory.database.stores.has(ONE_TALK_CONTACT_PROFILE_STORE_NAME), true);
assert.equal(
factory.database.stores.get("onetalk_messages").records.get("legacy-message").loginUserId,
undefined,
);
const candidate = factory.database.stores
.get("onetalk_sync_candidates")
.records.get("legacy-candidate");
assert.equal(candidate.loginUserId, undefined);
assert.equal(candidate.message.loginUserId, undefined);
});
test("does not return true before the readwrite transaction commits", async () => {
const factory = new Factory();
const store = createOneTalkContactProfileStore(factory, () => 100);
await store.putPendingProfile("account-1", profile());
factory.database.nextHeldTransaction = true;
const result = store.markProfileUploaded({
channelAccountId: "account-1",
aliId: profile().aliId,
fingerprint: "v1-profile",
uploadedAt: 200,
});
let settled = false;
void result.then(() => {
settled = true;
});
await tick();
assert.equal(settled, false);
factory.database.lastTransaction.complete();
assert.equal(await result, true);
const record = factory.database.stores
.get(ONE_TALK_CONTACT_PROFILE_STORE_NAME)
.records.get(contactProfileKey("account-1", profile().aliId));
assert.equal(record.pending, undefined);
assert.equal(record.lastUploadedFingerprint, "v1-profile");
});
test("rejects on transaction abort without reporting a committed profile", async () => {
const factory = new Factory();
const store = createOneTalkContactProfileStore(factory, () => 100);
await store.putPendingProfile("account-1", profile());
factory.database.nextHeldTransaction = true;
const result = store.markProfileUploaded({
channelAccountId: "account-1",
aliId: profile().aliId,
fingerprint: "v1-profile",
uploadedAt: 200,
});
await tick();
factory.database.lastTransaction.abort();
await assert.rejects(result, /aborted/);
const record = factory.database.stores
.get(ONE_TALK_CONTACT_PROFILE_STORE_NAME)
.records.get(contactProfileKey("account-1", profile().aliId));
assert.equal(record.pending.fingerprint, "v1-profile");
});
test("rejects on transaction error and preserves the pending profile", async () => {
const factory = new Factory();
const store = createOneTalkContactProfileStore(factory, () => 100);
await store.putPendingProfile("account-1", profile());
factory.database.nextHeldTransaction = true;
const result = store.markProfileUploaded({
channelAccountId: "account-1",
aliId: profile().aliId,
fingerprint: "v1-profile",
uploadedAt: 200,
});
await tick();
factory.database.lastTransaction.fail();
await assert.rejects(result, /failed/);
const record = factory.database.stores
.get(ONE_TALK_CONTACT_PROFILE_STORE_NAME)
.records.get(contactProfileKey("account-1", profile().aliId));
assert.equal(record.pending.fingerprint, "v1-profile");
});
@@ -7,6 +7,7 @@ import {
createOneTalkPageCommandMessage,
createOneTalkPageCommandResultMessage,
createOneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
decodeOneTalkPageMessage,
ONE_TALK_PAGE_BRIDGE_SOURCE,
ONE_TALK_PAGE_BRIDGE_VERSION,
@@ -34,6 +35,21 @@ const observedMessage = {
unreadCount: 0,
};
const profile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-profile",
observationStatus: "confirmed",
};
class FakeWebSocket extends EventTarget {
static OPEN = 1;
@@ -188,6 +204,20 @@ test("forwards only valid current-window MAIN messages once to the named Port",
assert.deepEqual(port.posted, [observed]);
});
test("keeps the profile envelope identity explicit and rejects sensitive or extra fields", () => {
const observed = createOneTalkPageProfileObservedMessage([profile], "login-account-1");
assert.deepEqual(decodeOneTalkPageMessage(observed), observed);
assert.equal(decodeOneTalkPageMessage({ ...observed, channelAccountId: "" }), null);
assert.equal(decodeOneTalkPageMessage({ ...observed, chatToken: "secret-chat-token" }), null);
assert.equal(
decodeOneTalkPageMessage({
...observed,
profiles: [{ ...profile, rawRow: { chatToken: "secret-raw-row" } }],
}),
null,
);
});
test("publishes a page registration to the current origin", () => {
const pageWindow = new FakePageWindow();
installOneTalkMainPageBridge(pageWindow);
@@ -7,9 +7,11 @@ import {
createOneTalkPageCommandResultMessage,
createOneTalkPageHelloMessage,
createOneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { createOneTalkServiceWorkerRuntime } from "../src/onetalk/service-worker/runtime.ts";
import { OneTalkPageRuntimeHost } from "../src/onetalk/service-worker/page-runtime-host.ts";
const pageUrl = "https://onetalk.alibaba.com/workbench/conversations";
@@ -28,6 +30,21 @@ const observedMessage = {
unreadCount: 0,
};
const profile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-profile",
observationStatus: "confirmed",
};
class FakePort {
constructor(sender, name = ONE_TALK_PAGE_PORT_NAME) {
this.name = name;
@@ -166,6 +183,126 @@ test("routes an account-level command only to the unique page without conversati
);
});
test("requests only one profile snapshot for repeated hello from the same page identity", async () => {
const engine = { handlePageReady: async () => {} };
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveChannelAccountId: () => "account-unique",
onError: () => {},
});
const port = new FakePort(pageSender(30));
host.runtime.handleConnect(port);
const repeatedHello = createOneTalkPageHelloMessage("account-unique", "conversation-unique");
port.dispatchMessage(repeatedHello);
port.dispatchMessage(repeatedHello);
for (const message of port.posted) {
port.dispatchMessage(
createOneTalkPageCommandResultMessage(message.requestId, { status: "completed" }),
);
}
await Promise.resolve();
assert.deepEqual(
port.posted.map((message) => message.command?.action),
["onetalk.contact.snapshot"],
);
});
test("requests a new profile snapshot when the page identity changes", async () => {
const engine = { handlePageReady: async () => {} };
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
onError: () => {},
});
const port = new FakePort(pageSender(31));
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-a"));
port.dispatchMessage(createOneTalkPageHelloMessage("account-b"));
assert.deepEqual(
port.posted.map((message) => message.command?.action),
["onetalk.contact.snapshot", "onetalk.contact.snapshot"],
);
});
test("does not pass an old page observation to a replacement-account engine", async () => {
const handled = [];
const engine = {
handlePageObservation: async (message, channelAccountId) => {
handled.push({ message, channelAccountId });
},
handlePageReady: async () => {},
};
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveChannelAccountId: () => "account-b",
onError: () => {},
});
const port = new FakePort(pageSender(30));
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-a"));
port.dispatchMessage(createOneTalkPageObservedMessage([{ messageId: "message-1" }]));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(handled, []);
});
test("durably gates profile messages and routes the account-level profile snapshot command", async () => {
const events = [];
const runtime = createOneTalkServiceWorkerRuntime({
persistPageProfileObservation: async (message, _sender, channelAccountId) => {
events.push(["persist", channelAccountId, message.profiles.length]);
await Promise.resolve();
events.push("persisted");
},
onPageProfileMessage: (message) => events.push(["handled", message.profiles[0].aliId]),
onPageMessage: () => undefined,
});
const port = new FakePort(pageSender(34));
connectPage(runtime, port, "account-1", undefined);
port.dispatchMessage(createOneTalkPageHelloMessage("account-1", undefined));
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-1"));
assert.deepEqual(events, [["persist", "account-1", 1]]);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(events, [
["persist", "account-1", 1],
"persisted",
["handled", profile.aliId],
]);
const result = runtime.routePageCommand({
channelAccountId: "account-1",
requestId: "profile-snapshot-1",
command: { action: "onetalk.contact.snapshot" },
});
assert.equal(port.posted.at(-1).command.action, "onetalk.contact.snapshot");
port.dispatchMessage(
createOneTalkPageCommandResultMessage("profile-snapshot-1", {
status: "completed",
profileCount: 1,
}),
);
assert.deepEqual(await result, { status: "completed", profileCount: 1 });
});
test("rejects a profile update whose page identity does not match the last hello", async () => {
const events = [];
const runtime = createOneTalkServiceWorkerRuntime({
persistPageProfileObservation: () => events.push("persisted"),
onPageProfileMessage: () => events.push("handled"),
onPageMessage: () => undefined,
});
const port = new FakePort(pageSender(35));
connectPage(runtime, port, "account-1", undefined);
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-2"));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(events, []);
});
test("diagnoses page routing outcomes without page or account identifiers", async () => {
const diagnostics = [];
const runtime = createOneTalkServiceWorkerRuntime({
@@ -8,6 +8,7 @@ import {
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
createOneTalkMessageStore,
} from "../src/onetalk/service-worker/storage.ts";
@@ -42,6 +43,7 @@ class FakeDatabase {
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
].includes(name),
true,
);
@@ -99,6 +101,7 @@ test("creates the message store and overwrites duplicate business keys", async (
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
].sort(),
);
assert.equal(factory.database.records.size, 1);
@@ -8,6 +8,7 @@ import {
createOneTalkPageHelloMessage,
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { OneTalkPageRuntimeHost } from "../src/onetalk/service-worker/page-runtime-host.ts";
import { createOneTalkServiceWorkerSyncController } from "../src/onetalk/service-worker/sync-runtime.ts";
const config = (suffix) => ({
@@ -224,3 +225,96 @@ test("replays a page hello that arrived before async configuration finished", as
}),
);
});
test("reports snapshot route failure without blocking page readiness", async () => {
const diagnostics = [];
const engine = { handlePageReady: async () => {} };
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => null,
getActiveChannelAccountId: () => "account-failure",
getConfigurationEpoch: () => 1,
onProfileDiagnostic: (event) => diagnostics.push(event),
onError: () => {},
});
const port = new FakePort(pageSender);
port.postMessage = () => {
throw new Error("stale page handler");
};
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-failure"));
await new Promise((resolve) => setImmediate(resolve));
assert.ok(
diagnostics.some(
(event) =>
event.event === "profile_snapshot" &&
event.status === "rejected" &&
event.code === "snapshot_unavailable",
),
);
});
test("does not let a delayed old page callback touch the replacement account", async () => {
let configurationEpoch = 1;
let activeAccount = "account-a";
let releasePageReady;
let pageReadyStarted;
const oldPageReady = new Promise((resolve) => {
releasePageReady = resolve;
});
const started = new Promise((resolve) => {
pageReadyStarted = resolve;
});
const oldEngine = {
handlePageReady: async () => {
pageReadyStarted();
await oldPageReady;
},
};
const nextEngine = { handlePageReady: async () => {} };
let oldProfileReadyCalls = 0;
let nextProfileReadyCalls = 0;
const oldProfile = {
handlePageReady: async () => {
oldProfileReadyCalls += 1;
},
};
const nextProfile = {
handlePageReady: async () => {
nextProfileReadyCalls += 1;
},
};
let activeEngine = oldEngine;
let activeProfile = oldProfile;
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => activeEngine,
getActiveProfileCoordinator: () => activeProfile,
getActiveChannelAccountId: () => activeAccount,
getConfigurationEpoch: () => configurationEpoch,
onProfileDiagnostic: () => {},
onError: () => {},
});
const port = new FakePort(pageSender);
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-a"));
await started;
const initialPostCount = port.posted.length;
configurationEpoch = 2;
activeAccount = "account-b";
activeEngine = nextEngine;
activeProfile = nextProfile;
const replacementPort = new FakePort(pageSender);
host.runtime.handleConnect(replacementPort);
replacementPort.dispatchMessage(createOneTalkPageHelloMessage("account-b"));
const replacementPostCount = replacementPort.posted.length;
releasePageReady();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(oldProfileReadyCalls, 0);
assert.equal(port.posted.length, initialPostCount);
assert.equal(nextProfileReadyCalls, 1);
assert.equal(replacementPort.posted.length, replacementPostCount);
assert.equal(replacementPort.posted.at(-1).command.action, "onetalk.contact.snapshot");
});
@@ -220,6 +220,7 @@ test("creates durable stores, keeps confirmed candidates, and merges anomalies",
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
"onetalk_contact_profiles",
].sort(),
);
});
+107 -1
View File
@@ -2,6 +2,9 @@
import {
ONETALK_CONNECTION_TYPES,
ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE,
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
ONETALK_CONTACT_PROFILE_STATUSES,
ONETALK_DIRECTIONS,
ONETALK_ERROR_CODES,
ONETALK_FRAME_TYPES,
@@ -18,6 +21,7 @@ import {
isOneTalkMindScope,
isOneTalkPluginScope,
type OneTalkAnchor,
type OneTalkContactProfile,
type OneTalkFrame,
type OneTalkFrameContext,
type OneTalkFrameType,
@@ -124,6 +128,78 @@ const isOneTalkMessageAckStatus = (value: unknown): boolean => {
return typeof value === "string" && ONETALK_MESSAGE_ACK_STATUSES.includes(value as never);
};
const PROFILE_KEYS = [
"conversationId",
"aliId",
"accountId",
"loginId",
"name",
"companyName",
"countryCode",
"currentTimeZone",
"serviceType",
"observedAtMs",
"profileFingerprint",
"observationStatus",
] as const;
const PROFILE_FRAME_REQUIRED_KEYS = [
"connectionType",
"payload",
"protocolVersion",
"requestId",
"scope",
"type",
] as const;
const PROFILE_FRAME_OPTIONAL_KEYS = ["sendRequestId"] as const;
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length && actual.every((key, index) => key === expected[index])
);
};
const hasExactProfileFrameKeys = (value: Record<string, unknown>): boolean => {
const allowedKeys = [...PROFILE_FRAME_REQUIRED_KEYS, ...PROFILE_FRAME_OPTIONAL_KEYS];
const actualKeys = Object.keys(value);
return (
PROFILE_FRAME_REQUIRED_KEYS.every((key) => actualKeys.includes(key)) &&
actualKeys.every((key) => allowedKeys.includes(key as (typeof allowedKeys)[number]))
);
};
export const isOneTalkContactProfile = (value: unknown): value is OneTalkContactProfile => {
if (!isRecord(value) || !hasExactKeys(value, PROFILE_KEYS)) return false;
return (
isNonEmptyString(value.conversationId) &&
isNonEmptyString(value.aliId) &&
(value.accountId === null || isNonEmptyString(value.accountId)) &&
(value.loginId === null || isNonEmptyString(value.loginId)) &&
(value.name === null || typeof value.name === "string") &&
(value.companyName === null || typeof value.companyName === "string") &&
(value.countryCode === null || typeof value.countryCode === "string") &&
(value.currentTimeZone === null || isFiniteNumber(value.currentTimeZone)) &&
(value.serviceType === null || typeof value.serviceType === "string") &&
typeof value.observedAtMs === "number" &&
Number.isSafeInteger(value.observedAtMs) &&
value.observedAtMs >= 0 &&
isNonEmptyString(value.profileFingerprint) &&
typeof value.observationStatus === "string" &&
ONETALK_CONTACT_PROFILE_STATUSES.includes(value.observationStatus as never)
);
};
const hasValidContactProfileFrameSize = (value: Record<string, unknown>): boolean => {
try {
const encoded = new TextEncoder().encode(JSON.stringify(value));
return encoded.byteLength <= ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES;
} catch {
return false;
}
};
export const isOneTalkSendResultStatus = (value: unknown): value is OneTalkSendResultStatus => {
return (
typeof value === "string" && ONETALK_SEND_RESULT_STATUSES.some((status) => status === value)
@@ -220,6 +296,9 @@ const hasValidFrameDirection = (
) {
return connectionType === "plugin";
}
if (type === "contact.profile.observed" || type === "contact.profile.ack") {
return connectionType === "plugin";
}
if (
type === "message.created" ||
type === "plugin.status" ||
@@ -239,6 +318,7 @@ const isValidPayload = (
type: OneTalkFrameType,
connectionType: OneTalkConnectionType,
value: unknown,
frameForProfile: Record<string, unknown> | null = null,
): boolean => {
if (!isRecord(value)) return false;
@@ -293,6 +373,28 @@ const isValidPayload = (
value.messageCount >= 0 &&
typeof value.anchorAdvanced === "boolean"
);
case "contact.profile.observed":
return (
frameForProfile !== null &&
hasExactProfileFrameKeys(frameForProfile) &&
hasExactKeys(value, ["profiles"]) &&
Array.isArray(value.profiles) &&
value.profiles.length > 0 &&
value.profiles.length <= ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE &&
value.profiles.every(isOneTalkContactProfile) &&
hasValidContactProfileFrameSize(frameForProfile)
);
case "contact.profile.ack":
return (
frameForProfile !== null &&
hasExactProfileFrameKeys(frameForProfile) &&
hasExactKeys(value, ["status", "profileCount"]) &&
value.status === "delivered" &&
typeof value.profileCount === "number" &&
Number.isSafeInteger(value.profileCount) &&
value.profileCount > 0 &&
value.profileCount <= ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE
);
case "message.observed":
return (
isOneTalkObservationSource(value.observationSource) &&
@@ -355,12 +457,16 @@ export const decodeOneTalkFrame = (value: unknown): OneTalkDecodeResult => {
}
const context = decodeOneTalkFrameContext(value);
const frameForProfile =
value.type === "contact.profile.observed" || value.type === "contact.profile.ack"
? value
: null;
if (
!context ||
!isOneTalkFrameType(value.type) ||
!hasValidFrameDirection(value.type, context.connectionType) ||
!hasRequiredSendRequestId(value) ||
!isValidPayload(value.type, context.connectionType, value.payload)
!isValidPayload(value.type, context.connectionType, value.payload, frameForProfile)
) {
return invalidMessage();
}
+64
View File
@@ -16,6 +16,8 @@ export const ONETALK_FRAME_TYPES = [
"plugin.status",
"sync.complete",
"sync.status",
"contact.profile.observed",
"contact.profile.ack",
"message.observed",
"message.ack",
"message.created",
@@ -31,6 +33,7 @@ export const ONETALK_CLIENT_FRAME_TYPES = [
"heartbeat",
"conversation.discovered",
"sync.complete",
"contact.profile.observed",
"message.observed",
"send.request",
"send.confirmation",
@@ -43,6 +46,7 @@ export const ONETALK_SERVER_FRAME_TYPES = [
"anchor.snapshot",
"plugin.status",
"sync.status",
"contact.profile.ack",
"message.ack",
"message.created",
"send.command",
@@ -231,6 +235,28 @@ export type OneTalkObservedMessage = {
unreadCount?: number;
};
export const ONETALK_CONTACT_PROFILE_STATUSES = ["confirmed", "partial"] as const;
export type OneTalkContactProfileObservationStatus =
(typeof ONETALK_CONTACT_PROFILE_STATUSES)[number];
export const ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE = 100;
export const ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES = 256 * 1024;
/** OneTalk 页面已经加载的、允许跨边界的基础联系人资料。 */
export type OneTalkContactProfile = {
conversationId: string;
aliId: string;
accountId: string | null;
loginId: string | null;
name: string | null;
companyName: string | null;
countryCode: string | null;
currentTimeZone: number | null;
serviceType: string | null;
observedAtMs: number;
profileFingerprint: string;
observationStatus: OneTalkContactProfileObservationStatus;
};
export type OneTalkAnchor = {
conversationId: string;
latestMessageId: string | null;
@@ -361,6 +387,30 @@ export type OneTalkMessageObservedFrame = OneTalkBaseFrame<
"plugin"
>;
export type OneTalkContactProfileObservedFrame = OneTalkBaseFrame<
"contact.profile.observed",
{ profiles: OneTalkContactProfile[] },
"plugin"
>;
export type OneTalkContactProfileAckFrame = OneTalkBaseFrame<
"contact.profile.ack",
{ status: "delivered"; profileCount: number },
"plugin"
>;
export const createOneTalkContactProfileObservedFrame = (
frame: OneTalkFrameContext & { connectionType: "plugin"; scope: OneTalkPluginScope },
profiles: OneTalkContactProfile[],
): OneTalkContactProfileObservedFrame => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "contact.profile.observed",
requestId: frame.requestId,
scope: frame.scope,
payload: { profiles: profiles.map((profile) => ({ ...profile })) },
});
export type OneTalkMessageAckPayload = {
status: OneTalkMessageAckStatus;
conversationId?: string;
@@ -423,6 +473,8 @@ export type OneTalkFrame =
| OneTalkPluginStatusFrame
| OneTalkSyncCompleteFrame
| OneTalkSyncStatusFrame
| OneTalkContactProfileObservedFrame
| OneTalkContactProfileAckFrame
| OneTalkMessageObservedFrame
| OneTalkMessageAckFrame
| OneTalkMessageCreatedFrame
@@ -554,6 +606,18 @@ export const createOneTalkMessageAckFrame = (
};
};
export const createOneTalkContactProfileAckFrame = (
frame: OneTalkFrameContext & { connectionType: "plugin"; scope: OneTalkPluginScope },
profileCount: number,
): OneTalkContactProfileAckFrame => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "contact.profile.ack",
requestId: frame.requestId,
scope: frame.scope,
payload: { status: "delivered", profileCount },
});
export const createOneTalkSendCommandFrame = (
frame: OneTalkFrameContext & { connectionType: "plugin"; scope: OneTalkPluginScope },
payload: OneTalkSendCommandFrame["payload"],
+138
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
createMockAuthorizationReader,
@@ -159,6 +160,143 @@ test("validates observed messages for history, incremental, live, and send confi
}
});
const contactProfile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-12345678",
observationStatus: "confirmed" as const,
};
test("strictly decodes profile frames and rejects sensitive or incomplete payloads", () => {
const observed = decode({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [contactProfile] },
});
assert.equal(observed.type, "contact.profile.observed");
const ack = decode({
...frameBase,
type: "contact.profile.ack",
payload: { status: "delivered", profileCount: 1 },
});
assert.equal(ack.type, "contact.profile.ack");
const sensitiveFields = [
["chatToken", "secret-chat-token"],
["aliIdEncrypt", "secret-ali-id"],
["accountIdEncrypt", "secret-account-id"],
["loginIdEncrypt", "secret-login-id"],
["kHTAccessToken", "secret-kht-token"],
["rawRow", { chatToken: "secret-raw-row" }],
] as const;
for (const [field, value] of sensitiveFields) {
const result = decodeOneTalkFrame({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [{ ...contactProfile, [field]: value }] },
});
assert.deepEqual(result, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
assert.equal(JSON.stringify(result).includes("secret"), false);
const topLevelResult = decodeOneTalkFrame({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [contactProfile] },
[field]: value,
});
assert.deepEqual(topLevelResult, {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
assert.equal(JSON.stringify(topLevelResult).includes("secret"), false);
}
const missingIdentity = decodeOneTalkFrame({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [{ ...contactProfile, aliId: "" }] },
});
assert.deepEqual(missingIdentity, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
const emptyBatch = decodeOneTalkFrame({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [] },
});
assert.deepEqual(emptyBatch, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
const emptyBatchWithSensitiveField = decodeOneTalkFrame({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [] },
chatToken: "secret-empty-batch",
});
assert.deepEqual(emptyBatchWithSensitiveField, {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
assert.equal(JSON.stringify(emptyBatchWithSensitiveField).includes("secret"), false);
});
test("requires profile observed and ack frames on the plugin connection", () => {
const mindFrameBase = {
...frameBase,
connectionType: "mind_page" as const,
scope: mindScope,
};
assert.deepEqual(
decodeOneTalkFrame({
...mindFrameBase,
type: "contact.profile.observed",
payload: { profiles: [contactProfile] },
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
assert.deepEqual(
decodeOneTalkFrame({
...mindFrameBase,
type: "contact.profile.ack",
payload: { status: "delivered", profileCount: 1 },
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
});
test("rejects a profile wire frame whose full serialized size exceeds the limit", () => {
const frameForName = (name: string) => ({
...frameBase,
type: "contact.profile.observed",
payload: { profiles: [{ ...contactProfile, name }] },
});
const emptyNamePayload = frameForName("").payload;
const nameLength =
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES -
new TextEncoder().encode(JSON.stringify(emptyNamePayload)).byteLength;
const oversizedFrame = frameForName("x".repeat(nameLength));
assert.equal(
new TextEncoder().encode(JSON.stringify(oversizedFrame.payload)).byteLength,
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
);
assert.ok(
new TextEncoder().encode(JSON.stringify(oversizedFrame)).byteLength >
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
);
assert.deepEqual(decodeOneTalkFrame(oversizedFrame), {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
});
test("keeps legacy observed frames decodable while raw observations remain JSON objects", () => {
const legacy = decode({
...frameBase,
+11
View File
@@ -21,6 +21,10 @@ import type { OneTalkConnectionRegistry, OneTalkPublishFailureSink } from "./web
import type { OneTalkService } from "./onetalk/index.ts";
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
import {
createMindContactProfileDelivery,
type OneTalkContactProfileDelivery,
} from "./mind-contact-profile.ts";
export type AppDependencies = {
database?: DatabaseConnection;
@@ -31,6 +35,7 @@ export type AppDependencies = {
onOneTalkDiagnostic?: OneTalkDiagnosticsSink;
onMindAuthorizationDiagnostic?: MindAuthorizationDiagnosticsSink;
cutoverPolicy?: OneTalkCutoverPolicy;
contactProfileDelivery?: OneTalkContactProfileDelivery;
};
const authorizationFor = (
@@ -69,6 +74,11 @@ export const createApp = (
const database = dependencies.database ?? createDatabase(config.databaseUrl);
const oneTalkService =
dependencies.oneTalkService ?? createOneTalkService(createOneTalkRepository(database.db));
const contactProfileDelivery =
dependencies.contactProfileDelivery ??
(config.mindAuthorization
? createMindContactProfileDelivery(config.mindAuthorization)
: undefined);
installHealthRoute(app);
installOneTalkHarnessRoute(app);
@@ -81,6 +91,7 @@ export const createApp = (
mindPageOrigin,
pluginOrigins,
cutoverPolicy,
contactProfileDelivery,
});
installOneTalkReadRoutes(app, {
authorization,
+94
View File
@@ -0,0 +1,94 @@
// 通过固定 Mind HTTP 端点投递清洗后的 OneTalk 联系人资料
import type { OneTalkContactProfile } from "@trade-message-center/onetalk-contract";
export const MIND_CONTACT_PROFILE_PATH = "/internal/bright/onetalk/contact-profiles";
export type OneTalkContactProfileDeliveryInput = {
channelAccountId: string;
binding: string;
profiles: OneTalkContactProfile[];
};
export type OneTalkContactProfileDeliveryResult =
| { delivered: true; httpStatusClass: "1xx" | "2xx" | "3xx" | "4xx" | "5xx" }
| { delivered: false; reason: "no_response" };
type HttpStatusClass = "1xx" | "2xx" | "3xx" | "4xx" | "5xx";
type MindContactProfile = Pick<
OneTalkContactProfile,
| "conversationId"
| "aliId"
| "accountId"
| "loginId"
| "name"
| "companyName"
| "countryCode"
| "currentTimeZone"
| "serviceType"
| "observedAtMs"
| "profileFingerprint"
| "observationStatus"
>;
export type OneTalkContactProfileDelivery = (
input: OneTalkContactProfileDeliveryInput,
) => Promise<OneTalkContactProfileDeliveryResult>;
export type MindContactProfileClientConfig = {
baseUrl: string;
timeoutMs: number;
fetch?: typeof fetch;
};
const statusClassFor = (status: number): HttpStatusClass => {
if (status >= 100 && status < 200) return "1xx";
if (status >= 200 && status < 300) return "2xx";
if (status >= 300 && status < 400) return "3xx";
if (status >= 400 && status < 500) return "4xx";
return "5xx";
};
const profileForMind = (profile: OneTalkContactProfile): MindContactProfile => ({
conversationId: profile.conversationId,
aliId: profile.aliId,
accountId: profile.accountId,
loginId: profile.loginId,
name: profile.name,
companyName: profile.companyName,
countryCode: profile.countryCode,
currentTimeZone: profile.currentTimeZone,
serviceType: profile.serviceType,
observedAtMs: profile.observedAtMs,
profileFingerprint: profile.profileFingerprint,
observationStatus: profile.observationStatus,
});
const bodyFor = (input: OneTalkContactProfileDeliveryInput): string =>
JSON.stringify({
channelAccountId: input.channelAccountId,
binding: input.binding,
profiles: input.profiles.map(profileForMind),
});
/** 创建只等待 Mind Response headers 的 profile delivery adapter。 */
export const createMindContactProfileDelivery = (
config: MindContactProfileClientConfig,
): OneTalkContactProfileDelivery => {
const fetchImplementation = config.fetch ?? fetch;
const endpoint = new URL(MIND_CONTACT_PROFILE_PATH, config.baseUrl);
return async (input) => {
try {
const response = await fetchImplementation(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: bodyFor(input),
signal: AbortSignal.timeout(config.timeoutMs),
redirect: "error",
});
return { delivered: true, httpStatusClass: statusClassFor(response.status) };
} catch {
return { delivered: false, reason: "no_response" };
}
};
};
+8 -1
View File
@@ -1,6 +1,11 @@
// 定义 OneTalk WebSocket 的安全、可注入开发诊断出口
export type OneTalkDiagnosticEvent = "ws_hello" | "ws_decision" | "ws_frame" | "ws_close";
export type OneTalkDiagnosticEvent =
| "ws_hello"
| "ws_decision"
| "ws_frame"
| "ws_close"
| "profile_delivery";
export type OneTalkDiagnosticDirection = "inbound" | "outbound";
@@ -13,6 +18,8 @@ export type OneTalkDiagnostic = {
code?: string;
closeCode?: number;
closeReason?: string;
profileCount?: number;
durationMs?: number;
};
export type OneTalkDiagnosticsSink = (event: OneTalkDiagnostic) => void;
+105 -1
View File
@@ -6,6 +6,7 @@ import {
ONETALK_ERROR_CODES,
createOneTalkAcceptedFrame,
createOneTalkAnchorSnapshotFrame,
createOneTalkContactProfileAckFrame,
createOneTalkErrorFrame,
createOneTalkHeartbeatAckFrame,
createOneTalkMessageAckFrame,
@@ -39,6 +40,7 @@ import {
} from "./registry.ts";
import type { OneTalkDiagnosticsSink } from "./diagnostics.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
import type { OneTalkContactProfileDelivery } from "../mind-contact-profile.ts";
const WEBSOCKET_OPEN = 1;
const CLOSE_UNSUPPORTED_DATA = 1003;
@@ -75,6 +77,7 @@ export type OneTalkWebSocketHandlerOptions = {
};
cutoverPolicy?: OneTalkCutoverPolicy;
expectedConnectionType?: "plugin" | "mind_page";
contactProfileDelivery?: OneTalkContactProfileDelivery;
};
const reportDiagnostic = (
@@ -228,6 +231,39 @@ const policyIsCurrent = (
);
};
const reauthorizeProfileDelivery = async (
frame: Extract<OneTalkFrame, { type: "contact.profile.observed" }>,
state: ConnectionState,
options: OneTalkWebSocketHandlerOptions,
): Promise<{ ok: true } | { ok: false; code: OneTalkErrorCode }> => {
if (frame.connectionType !== "plugin" || !isOneTalkPluginScope(frame.scope)) {
return { ok: false, code: ONETALK_ERROR_CODES.authorizationRejected };
}
const decision = await authorize(options.authorization, {
connectionType: "plugin",
operation: "sync",
scope: frame.scope,
binding: state.binding ?? "",
});
if (!decision.allowed) return { ok: false, code: decision.code };
if (state.binding === undefined || decision.binding !== state.binding) {
return { ok: false, code: ONETALK_ERROR_CODES.bindingRevoked };
}
if (
state.authorizationVersion === undefined ||
decision.authorizationVersion !== state.authorizationVersion
) {
return { ok: false, code: ONETALK_ERROR_CODES.authorizationVersionChanged };
}
if (state.mindScope === undefined || !isSameOneTalkScope(state.mindScope, decision.mindScope)) {
return { ok: false, code: ONETALK_ERROR_CODES.scopeMismatch };
}
if (!state.permissions.includes("read") || !decision.permissions.includes("read")) {
return { ok: false, code: ONETALK_ERROR_CODES.authorizationRejected };
}
return { ok: true };
};
const closeForPause = (socket: WebSocket, state: ConnectionState): void => {
state.unregister?.();
closeSocket(socket, CLOSE_TRY_AGAIN_LATER, "authorization_unavailable");
@@ -246,6 +282,7 @@ const requiresPluginConnection = (frame: OneTalkFrame): boolean => {
frame.type === "conversation.discovered" ||
frame.type === "sync.complete" ||
frame.type === "message.observed" ||
frame.type === "contact.profile.observed" ||
frame.type === "send.confirmation"
);
};
@@ -295,7 +332,8 @@ const authorizationOperationFor = (
if (
type === "conversation.discovered" ||
type === "sync.complete" ||
type === "message.observed"
type === "message.observed" ||
type === "contact.profile.observed"
) {
return "sync";
}
@@ -675,6 +713,72 @@ const handleAuthenticatedFrame = async (
}
const commitGuard = options.registry.createCommitGuard(canonical, policyEpoch);
if (frame.type === "contact.profile.observed") {
if (!options.contactProfileDelivery) {
reportDiagnostic(options.onDiagnostic, {
event: "ws_frame",
requestId: frame.requestId,
connectionType: "plugin",
frameType: frame.type,
code: "profile_delivery_unavailable",
});
return;
}
const startedAt = Date.now();
let delivery;
try {
delivery = await options.contactProfileDelivery({
channelAccountId: context.channelAccountId,
binding: context.binding,
profiles: frame.payload.profiles,
});
} catch {
delivery = { delivered: false as const, reason: "no_response" as const };
}
reportDiagnostic(options.onDiagnostic, {
event: "profile_delivery",
requestId: frame.requestId,
connectionType: "plugin",
frameType: frame.type,
code: delivery.delivered ? delivery.httpStatusClass : delivery.reason,
profileCount: frame.payload.profiles.length,
durationMs: Math.max(0, Date.now() - startedAt),
});
if (!delivery.delivered) return;
const currentAuthorization = await reauthorizeProfileDelivery(frame, state, options);
if (!policyIsCurrent(options, policyEpoch, "plugin")) {
closeForPause(socket, state);
return;
}
try {
commitGuard.assertValid();
} catch (error) {
if (isCommitGuardFailure(error)) return;
throw error;
}
const currentCanonical = options.registry.getCanonicalConnection(socket);
if (currentCanonical !== canonical) return;
if (!currentAuthorization.ok) {
reportDiagnostic(options.onDiagnostic, {
event: "ws_decision",
requestId: frame.requestId,
connectionType: "plugin",
frameType: frame.type,
code: currentAuthorization.code,
});
state.unregister?.();
sendError(socket, frame, currentAuthorization.code, options.onDiagnostic);
closeSocket(socket, CLOSE_POLICY_VIOLATION, currentAuthorization.code);
return;
}
sendFrame(
socket,
createOneTalkContactProfileAckFrame(frame, frame.payload.profiles.length),
options.onDiagnostic,
);
return;
}
if (frame.type === "conversation.discovered") {
try {
const conversation = await options.service.discoverConversation(
+5
View File
@@ -17,6 +17,7 @@ import {
import type { OneTalkService } from "../onetalk/index.ts";
import type { OneTalkDiagnosticsSink } from "./diagnostics.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
import type { OneTalkContactProfileDelivery } from "../mind-contact-profile.ts";
const reportDiagnostic = (
sink: OneTalkDiagnosticsSink | undefined,
@@ -38,6 +39,7 @@ export type WebsocketOptions = {
mindPageOrigin?: string;
pluginOrigins?: string[];
cutoverPolicy?: OneTalkCutoverPolicy;
contactProfileDelivery?: OneTalkContactProfileDelivery;
};
const registerWebsocketRoutes = (
@@ -48,6 +50,7 @@ const registerWebsocketRoutes = (
mindPageOrigin: string | undefined,
pluginOrigins: string[] | undefined,
cutoverPolicy: OneTalkCutoverPolicy | undefined,
contactProfileDelivery: OneTalkContactProfileDelivery | undefined,
): FastifyPluginCallback => {
return (app, _options, done) => {
app.addHook("onRequest", async (request, reply) => {
@@ -105,6 +108,7 @@ const registerWebsocketRoutes = (
},
cutoverPolicy,
expectedConnectionType,
contactProfileDelivery,
})(socket);
app.get("/ws/plugin", { websocket: true }, route("plugin"));
app.get("/ws/mind", { websocket: true }, route("mind_page"));
@@ -152,6 +156,7 @@ export const installWebsocket = (
options.mindPageOrigin,
options.pluginOrigins,
options.cutoverPolicy,
options.contactProfileDelivery,
),
);
return registry;
@@ -0,0 +1,178 @@
// 验证 Mind profile HTTP adapter 的白名单和 response/no-response 语义
import assert from "node:assert/strict";
import test from "node:test";
import {
createMindContactProfileDelivery,
MIND_CONTACT_PROFILE_PATH,
} from "../src/mind-contact-profile.ts";
const profile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-profile",
observationStatus: "confirmed" as const,
};
test("sends only the profile delivery body and ACKs any HTTP response class", async () => {
for (const status of [100, 200, 204, 302, 400, 404, 500, 503]) {
let url = "";
let request: RequestInit | undefined;
const delivery = createMindContactProfileDelivery({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
fetch: async (input, init) => {
url = String(input);
request = init;
return { status } as Response;
},
});
const result = await delivery({
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
});
assert.equal(result.delivered, true);
assert.equal(url, `https://mind.example.com${MIND_CONTACT_PROFILE_PATH}`);
assert.deepEqual(JSON.parse(String(request?.body)), {
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
});
assert.deepEqual(request?.headers, { "content-type": "application/json" });
}
});
test("picks profile fields at the runtime boundary", async () => {
let body = "";
const delivery = createMindContactProfileDelivery({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
fetch: async (_input, init) => {
body = String(init?.body);
return { status: 200 } as Response;
},
});
const runtimeProfile = {
...profile,
chatToken: "secret-chat-token",
aliIdEncrypt: "secret-ali-id",
accountIdEncrypt: "secret-account-id",
loginIdEncrypt: "secret-login-id",
kHTAccessToken: "secret-kht-token",
rawRow: { secret: "raw" },
deviceId: "device-1",
mindUserId: "mind-user-1",
workspaceId: "workspace-1",
Cookie: "secret-cookie",
} as typeof profile & Record<string, unknown>;
assert.deepEqual(
await delivery({
channelAccountId: "account-1",
binding: "binding-1",
profiles: [runtimeProfile],
}),
{ delivered: true, httpStatusClass: "2xx" },
);
assert.deepEqual(JSON.parse(body), {
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
});
for (const secret of [
"secret-chat-token",
"secret-ali-id",
"secret-account-id",
"secret-login-id",
"secret-kht-token",
"secret-cookie",
]) {
assert.equal(body.includes(secret), false);
}
});
test("returns no_response when the AbortSignal timeout fires", async () => {
const delivery = createMindContactProfileDelivery({
baseUrl: "https://mind.example.com",
timeoutMs: 1,
fetch: async (_input, init) =>
await new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("missing signal"));
return;
}
const keepEventLoopAlive = setTimeout(() => reject(new Error("late fetch")), 25);
signal.addEventListener(
"abort",
() => {
clearTimeout(keepEventLoopAlive);
reject(signal.reason);
},
{ once: true },
);
}),
});
assert.deepEqual(
await delivery({
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
}),
{ delivered: false, reason: "no_response" },
);
});
test("does not ACK a network failure and never reads a response body", async () => {
const delivery = createMindContactProfileDelivery({
baseUrl: "https://mind.example.com",
timeoutMs: 1,
fetch: async () => {
throw new Error("network");
},
});
assert.deepEqual(
await delivery({
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
}),
{ delivered: false, reason: "no_response" },
);
let bodyRead = false;
const response = {
status: 503,
get body() {
bodyRead = true;
return null;
},
} as unknown as Response;
const responseDelivery = createMindContactProfileDelivery({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
fetch: async () => response,
});
assert.equal(
(
await responseDelivery({
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
})
).delivered,
true,
);
assert.equal(bodyRead, false);
});
@@ -0,0 +1,498 @@
// 验证 profile frame 的授权、Mind 投递和 ACK 边界
import assert from "node:assert/strict";
import test from "node:test";
import type { WebSocket } from "@fastify/websocket";
import {
ONETALK_PROTOCOL_VERSION,
createMockAuthorizationReader,
type MockAuthorizationRecord,
type OneTalkContactProfile,
} from "@trade-message-center/onetalk-contract";
import { createApp } from "../src/app.ts";
import type { DatabaseConnection } from "../src/database/index.ts";
import {
type OneTalkContactProfileDeliveryInput,
type OneTalkContactProfileDeliveryResult,
} from "../src/mind-contact-profile.ts";
import type { OneTalkService } from "../src/onetalk/index.ts";
import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts";
const config = {
host: "127.0.0.1",
port: 3000,
databaseUrl: "postgres://test:test@localhost:5432/test",
environment: "non_development" as const,
mindAuthorization: {
baseUrl: "https://mind.example.com",
mindPageOrigin: "http://mind.localhost",
pluginOrigins: ["http://plugin.localhost"],
timeoutMs: 100,
},
};
const pluginScope = { channelAccountId: "account-1", deviceId: "device-1" } as const;
const authorizationRecord: MockAuthorizationRecord = {
scope: pluginScope,
mindScope: {
mindUserId: "mind-user-1",
workspaceId: "workspace-1",
channelAccountId: pluginScope.channelAccountId,
},
binding: "binding-1",
permissions: ["read", "send"],
authorizationVersion: "version-1",
active: true,
};
const profile: OneTalkContactProfile = {
conversationId: "conversation-1",
aliId: "2208314000798",
accountId: "243340382",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hago",
countryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
observedAtMs: 1_700_000_000_000,
profileFingerprint: "v1-profile",
observationStatus: "confirmed" as const,
};
const createDatabaseStub = (): DatabaseConnection => ({
db: {} as DatabaseConnection["db"],
close: async () => undefined,
});
const service: OneTalkService = {
discoverConversation: async () => ({
channelAccountId: pluginScope.channelAccountId,
conversationId: "conversation-1",
syncPhase: "initial" as const,
syncResult: "incomplete" as const,
latestMessageId: null,
historyComplete: false,
messageCount: 0,
}),
listAnchors: async () => [],
listConversations: async () => [],
readConversation: async () => null,
readHistory: async () => null,
observeMessage: async () => ({
status: "rejected",
reason: "conversation_not_discovered",
}),
completeSync: async () => ({
status: "accepted" as const,
conversation: {
channelAccountId: pluginScope.channelAccountId,
conversationId: "conversation-1",
syncPhase: "initial" as const,
syncResult: "incomplete" as const,
latestMessageId: null,
historyComplete: false,
messageCount: 0,
},
anchorAdvanced: false,
}),
};
const openPlugin = async (
app: ReturnType<typeof createApp>,
origin = "http://plugin.localhost",
): Promise<WebSocket> => {
await app.ready();
return app.injectWS("/ws/plugin", { headers: { origin } });
};
const nextMessage = (socket: WebSocket): Promise<Record<string, unknown>> =>
new Promise((resolve, reject) => {
socket.once("message", (data: Buffer) => {
try {
resolve(JSON.parse(data.toString()));
} catch (error) {
reject(error);
}
});
socket.once("error", reject);
});
const nextMessages = (socket: WebSocket, count: number): Promise<Record<string, unknown>[]> => {
return new Promise((resolve, reject) => {
const frames: Record<string, unknown>[] = [];
const onMessage = (data: Buffer): void => {
try {
frames.push(JSON.parse(data.toString()));
if (frames.length !== count) return;
socket.off("message", onMessage);
resolve(frames);
} catch (error) {
socket.off("message", onMessage);
reject(error);
}
};
socket.on("message", onMessage);
socket.once("error", reject);
});
};
const connect = async (socket: WebSocket): Promise<void> => {
const handshake = nextMessages(socket, 2);
socket.send(
JSON.stringify({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "ws.hello",
requestId: "hello",
scope: pluginScope,
payload: { binding: "binding-1", requestedPermissions: ["read"] },
}),
);
const frames = await handshake;
assert.deepEqual(
frames.map((frame) => frame.type),
["ws.accepted", "anchor.snapshot"],
);
};
const profileFrame = (profileValue: unknown = profile): Record<string, unknown> => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "contact.profile.observed",
requestId: "profile-1",
scope: pluginScope,
payload: { profiles: [profileValue] },
});
const closeApp = async (app: ReturnType<typeof createApp>, socket: WebSocket): Promise<void> => {
socket.terminate();
for (const client of app.websocketServer.clients) client.terminate();
await app.close();
};
const deferredDelivery = (): {
started: Promise<void>;
resolve: (result: OneTalkContactProfileDeliveryResult) => void;
delivery: (
input: OneTalkContactProfileDeliveryInput,
) => Promise<OneTalkContactProfileDeliveryResult>;
} => {
let resolveStarted!: () => void;
let resolveDelivery!: (result: OneTalkContactProfileDeliveryResult) => void;
const started = new Promise<void>((resolve) => {
resolveStarted = resolve;
});
const deliveryPromise = new Promise<OneTalkContactProfileDeliveryResult>((resolve) => {
resolveDelivery = resolve;
});
return {
started,
resolve: resolveDelivery,
delivery: async () => {
resolveStarted();
return deliveryPromise;
},
};
};
const wait = async (milliseconds: number): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
};
const assertNoAckDuring = async (socket: WebSocket, milliseconds = 30): Promise<void> => {
const received: Record<string, unknown>[] = [];
const onMessage = (data: Buffer): void => {
received.push(JSON.parse(data.toString()) as Record<string, unknown>);
};
socket.on("message", onMessage);
await wait(milliseconds);
socket.off("message", onMessage);
assert.equal(
received.some((frame) => frame.type === "contact.profile.ack"),
false,
);
};
test("uses canonical plugin authorization and ACKs any Mind HTTP response", async () => {
const calls: OneTalkContactProfileDeliveryInput[] = [];
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
contactProfileDelivery: async (input: OneTalkContactProfileDeliveryInput) => {
calls.push(input);
return { delivered: true, httpStatusClass: "5xx" };
},
});
const socket = await openPlugin(app);
try {
await connect(socket);
const ack = nextMessage(socket);
socket.send(JSON.stringify(profileFrame()));
const frame = await ack;
assert.equal(frame.type, "contact.profile.ack");
assert.deepEqual(frame.payload, { status: "delivered", profileCount: 1 });
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {
channelAccountId: "account-1",
binding: "binding-1",
profiles: [profile],
});
} finally {
await closeApp(app, socket);
}
});
test("does not call Mind or ACK an invalid/sensitive profile payload", async () => {
let calls = 0;
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
contactProfileDelivery: async () => {
calls += 1;
return { delivered: true, httpStatusClass: "2xx" };
},
});
const socket = await openPlugin(app);
try {
await connect(socket);
const error = nextMessage(socket);
socket.send(
JSON.stringify(
profileFrame({
...profile,
aliId: "",
chatToken: "secret",
} as unknown as Record<string, unknown>),
),
);
const frame = await error;
assert.equal(frame.type, "ws.error");
assert.equal(calls, 0);
} finally {
await closeApp(app, socket);
}
});
test("does not deliver or emit an invalid ACK for an empty profile batch", async () => {
let calls = 0;
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
contactProfileDelivery: async () => {
calls += 1;
return { delivered: true, httpStatusClass: "2xx" };
},
});
const socket = await openPlugin(app);
try {
await connect(socket);
const result = nextMessage(socket);
socket.send(JSON.stringify({ ...profileFrame(), payload: { profiles: [] } }));
const frame = await result;
assert.equal(frame.type, "ws.error");
assert.equal(calls, 0);
} finally {
await closeApp(app, socket);
}
});
test("fails closed without a profile delivery dependency", async () => {
const app = createApp(
{ ...config, environment: "development" as const, mindAuthorization: undefined },
{
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
},
);
const socket = await openPlugin(app, "chrome-extension://development-extension");
try {
await connect(socket);
let received = false;
socket.once("message", () => {
received = true;
});
socket.send(JSON.stringify(profileFrame()));
await wait(25);
assert.equal(received, false);
} finally {
await closeApp(app, socket);
}
});
test("keeps pending on a no-response delivery", async () => {
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
contactProfileDelivery: async () => ({ delivered: false, reason: "no_response" as const }),
});
const socket = await openPlugin(app);
try {
await connect(socket);
let received = false;
socket.once("message", () => {
received = true;
});
socket.send(JSON.stringify(profileFrame()));
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(received, false);
} finally {
await closeApp(app, socket);
}
});
test("keeps pending when profile delivery throws", async () => {
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
contactProfileDelivery: async () => {
throw new Error("delivery failed");
},
});
const socket = await openPlugin(app);
try {
await connect(socket);
let received = false;
socket.once("message", () => {
received = true;
});
socket.send(JSON.stringify(profileFrame()));
await wait(25);
assert.equal(received, false);
} finally {
await closeApp(app, socket);
}
});
test("rechecks binding authorization before ACK after delivery", async () => {
const authorization = createMockAuthorizationReader([authorizationRecord]);
const pending = deferredDelivery();
const app = createApp(config, {
database: createDatabaseStub(),
authorization,
oneTalkService: service,
contactProfileDelivery: pending.delivery,
});
const socket = await openPlugin(app);
try {
await connect(socket);
const result = nextMessage(socket);
socket.send(JSON.stringify(profileFrame()));
await pending.started;
authorization.revoke(pluginScope, "binding-1");
pending.resolve({ delivered: true, httpStatusClass: "2xx" });
const frame = await result;
assert.equal(frame.type, "ws.error");
assert.equal((frame.payload as Record<string, unknown>).code, "binding_revoked");
} finally {
await closeApp(app, socket);
}
});
test("rechecks authorization version before ACK after delivery", async () => {
const authorization = createMockAuthorizationReader([authorizationRecord]);
const pending = deferredDelivery();
const app = createApp(config, {
database: createDatabaseStub(),
authorization,
oneTalkService: service,
contactProfileDelivery: pending.delivery,
});
const socket = await openPlugin(app);
try {
await connect(socket);
const result = nextMessage(socket);
socket.send(JSON.stringify(profileFrame()));
await pending.started;
authorization.upsert({ ...authorizationRecord, authorizationVersion: "version-2" });
pending.resolve({ delivered: true, httpStatusClass: "2xx" });
const frame = await result;
assert.equal(frame.type, "ws.error");
assert.equal(
(frame.payload as Record<string, unknown>).code,
"authorization_version_changed",
);
} finally {
await closeApp(app, socket);
}
});
test("rechecks read permission before ACK after delivery", async () => {
const authorization = createMockAuthorizationReader([authorizationRecord]);
const pending = deferredDelivery();
const app = createApp(config, {
database: createDatabaseStub(),
authorization,
oneTalkService: service,
contactProfileDelivery: pending.delivery,
});
const socket = await openPlugin(app);
try {
await connect(socket);
const result = nextMessage(socket);
socket.send(JSON.stringify(profileFrame()));
await pending.started;
authorization.upsert({ ...authorizationRecord, permissions: ["send"] });
pending.resolve({ delivered: true, httpStatusClass: "2xx" });
const frame = await result;
assert.equal(frame.type, "ws.error");
assert.equal((frame.payload as Record<string, unknown>).code, "authorization_rejected");
} finally {
await closeApp(app, socket);
}
});
test("does not emit a late profile ACK after policy pause", async () => {
const pending = deferredDelivery();
const cutoverPolicy = createOneTalkCutoverPolicy();
const app = createApp(config, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
cutoverPolicy,
contactProfileDelivery: pending.delivery,
});
const socket = await openPlugin(app);
try {
await connect(socket);
socket.send(JSON.stringify(profileFrame()));
await pending.started;
cutoverPolicy.pause();
pending.resolve({ delivered: true, httpStatusClass: "2xx" });
await assertNoAckDuring(socket);
} finally {
await closeApp(app, socket);
}
});
test("does not emit a late profile ACK after canonical plugin replacement", async () => {
const pending = deferredDelivery();
const authorization = createMockAuthorizationReader([authorizationRecord]);
const app = createApp(config, {
database: createDatabaseStub(),
authorization,
oneTalkService: service,
contactProfileDelivery: pending.delivery,
});
const socket = await openPlugin(app);
let replacement: WebSocket | undefined;
try {
await connect(socket);
socket.send(JSON.stringify(profileFrame()));
await pending.started;
replacement = await openPlugin(app);
await connect(replacement);
pending.resolve({ delivered: true, httpStatusClass: "2xx" });
await assertNoAckDuring(socket);
} finally {
if (replacement) replacement.terminate();
await closeApp(app, socket);
}
});
+19 -13
View File
@@ -6,6 +6,8 @@
>
> 本文记录一次对真实 Chromium 页面运行时的只读探查结果,重点是如何获取会话对应的客户姓名、公司、登录 ID、阿里 ID 等资料,以及如何继续刷新客户详情。页面和静态 bundle 版本可能变化,生产代码必须保留特征检测、超时和字段白名单。
> **当前任务边界(2026-08-31)**:本文是历史探查证据,不是当前跨层实现合同。当前第一阶段只允许 MAIN world 读取已经加载的白名单基础资料,并通过现有 Bright WebSocket 投递;详情刷新、邮箱、注册时间、买家标签、DOM 适配和群聊成员属于未来范围。文中出现的 token、加密 ID 和内部详情调用只用于说明探查结果,禁止进入页面桥、Service Worker、Bright、Mind、日志或持久化。
## 1. 结论
当前 OneTalk 页面有三层资料入口:
@@ -14,11 +16,11 @@
会话列表模块已经把客户资料放在这个全局对象中。对于已经加载到会话列表的联系人,不需要额外请求,就能取得 `aliId``loginId`、姓名、公司等字段。
2. **补刷新`conversationServiceHttp.getConversationContactDetailList()`**
2. **延期的详情刷新探查`conversationServiceHttp.getConversationContactDetailList()`**
`window.IcbuIM.IMBaaSSDK` 中具体的 `IcbuConversationServiceImpl` 实例带有内部 HTTP 适配器。该适配器可以用页面已有的加密 ID 和 chat token 刷新联系人资料。
3. **完整客户详情:客户详情微应用自己的 `contactMemberInfo` 请求**
3. **延期的完整客户详情:客户详情微应用自己的 `contactMemberInfo` 请求**
右侧“客户详情”面板中的邮箱、注册时间等字段不在当前基础联系人对象中。静态 bundle 显示客户详情微应用会调用 `/message/contact/detail/contactMemberInfo.htm`。这部分应优先通过页面微应用已有逻辑或已渲染 DOM 获取,不要在插件 Service Worker 中自行拼接 CRM 请求。
@@ -31,7 +33,7 @@ window.__conversationListData__
→ 发送给 Mind
```
缺资料时再走:
缺资料时再走(历史探查建议,当前任务禁止)
```text
IcbuConversationServiceImpl.getInstance()
@@ -564,7 +566,9 @@ channelAccountId + aliId
- 群聊:会话标题不是联系人资料;需要使用成员列表服务逐个得到成员,再分别匹配 `aliId/loginId`
- 当前 `getConversationContactDetailList` 可以批量传多个联系人对象,但每个联系人仍必须带自己的加密 ID和页面 token。
## 7. 向 Mind 发送的建议数据契约
## 7. 向 Mind 发送的历史建议(不是当前实现契约
以下结构保留作 2026-08-27 探查记录。当前实现以共享 OneTalk contract、profile page envelope、Bright frame 和 Mind profile HTTP 规范为准,不使用本节的旧顶层字段结构。
客户资料应作为独立资料事件发送,不要附加到消息正文,也不要把原始会话对象当作 payload:
@@ -610,19 +614,19 @@ apps/chrome-extension/src/onetalk/main-page/contact-observer/
1. 读取 `window.__conversationListData__` 初始快照;
2. 订阅 `im-conversation-list:syncData`
3. 对资料缺失的会话调用 `conversationServiceHttp.getConversationContactDetailList`
4. 对邮箱、注册时间等完整详情使用页面微应用状态或 DOM 补充;
3. **未来范围**对资料缺失的会话调用 `conversationServiceHttp.getConversationContactDetailList`
4. **未来范围**对邮箱、注册时间等完整详情使用页面微应用状态或 DOM 补充;
5. 对返回对象执行字段白名单清洗;
6. 通过已有 page bridge / Service Worker 发送给 Mind
7.`channelAccountId + aliId` 去重;
8. 对每个联系人记录成功、部分成功、超时和 ID 不匹配状态。
推荐采集策略:
上面的详情刷新策略是未来范围。当前第一阶段策略:
```text
实时会话列表更新 → 立即采集基础资料
用户打开会话 → 按需刷新完整资料
显式回填客户资料” → 对缺资料会话批量补采集
实时会话列表更新 → 立即采集已加载的白名单基础资料
首次有效页面 hello → 请求当前已加载单聊 snapshot
邮箱/注册时间/买家标签/显式回填 → 另立 task
```
不要对每一条消息重复请求客户资料;应按联系人去重。
@@ -669,9 +673,9 @@ CSRF
完整原始响应
```
### 9.3 页面离线
### 9.3 页面离线与未来详情刷新
本次第一次调用外层空 Promise 时没有任何请求;改用内部 HTTP 适配器后能收到 200 响应。若页面自身网络状态为断开,内部方法可能长时间不完成,因此必须在插件侧设置明确超时,并把结果标记为 `timeout`,不能无限等待
本次第一次调用外层空 Promise 时没有任何请求;改用内部 HTTP 适配器后能收到 200 响应。这是历史探查结果,不是当前任务的调用授权。若未来任务重新启用详情刷新,必须另行定义 endpoint、token 边界和超时;当前 profile snapshot 命令本身是 fire-and-forget,不能阻塞消息 bootstrap
## 10. 验证记录
@@ -691,7 +695,9 @@ CSRF
| 右侧 DOM | 包含公司、邮箱、注册时间、买家标签 |
| 基础 SDK 返回邮箱/注册时间 | 当前未包含 |
## 11. 最小可用实现示例
## 11. 历史最小示例(不可直接用于当前任务)
本节代码展示当时探查到的内部详情刷新方式,故意保留为未来任务的证据。当前实现不得调用其中的 HTTP 详情方法,也不得让 chatToken 或加密 ID 离开 MAIN world。
下面是只返回安全字段的最小示例。它应当在 OneTalk 页面 MAIN world 中执行:
+6 -3
View File
@@ -124,7 +124,8 @@
- R24. 新 `onetalk_message` 表不得直接复制或转换 TradeMind 旧 OneTalk 消息;历史数据只能由插件从精确 OneTalk 页面重新观察、验证并重建。
- R25. 未经插件重新确认的 TradeMind 旧 OneTalk 消息冻结为 legacy 数据,不得参与新链路的发送确认、实时同步、幂等判断或事实读取。
- R25a. 插件从 OneTalk 页面枚举到会话后,Bright 才能创建 `channelAccountId + conversationId` 技术会话同步进度;Mind 只能读取 Bright 已发现的会话,不能主动制造 OneTalk 会话事实。
- R25b. senderId/loginUserId 四字段齐全时消息正常入 Bright;Mind 仅用现有业务逻辑按 ID 查询已有联系人/客户资料,查不到时显示原始 senderId 或“未关联联系人”。本项目不自动创建客户或补全资料。
- R25b. senderId/loginUserId 四字段齐全时消息正常入 Bright;Mind 仅用现有业务逻辑按 ID 查询已有联系人/客户资料,查不到时显示原始 senderId 或“未关联联系人”。消息链路不自动创建客户,也不调用客户详情接口补全消息资料。
- R25c. OneTalk 页面可以在 MAIN world 观察当前已加载单聊的白名单基础资料,并通过独立的 profile observation 链路投递给 Mind;这不改变消息事实归属,也不表示 Mind 已完成 DB 落库。
### Full rebuild and incremental anchor
@@ -162,7 +163,8 @@
### Observability and security
- R37. 每个发送请求、插件检查结果、重建会话、分页批次和消息写入必须保留可关联的结构化、非敏感诊断,至少包含 request ID、binding、账号范围、会话 ID、结果分类和时间戳;诊断不得记录 binding 原值、Cookie、CSRF、令牌或不必要的消息正文。
- R38. 插件不得直接调用 OneTalk CRM 端点、读取或重放 Cookie/CSRF/反爬令牌、接收任意远程 selector/script,或使用模糊客户名和列表位置点击。
- R38. 插件不得直接调用 OneTalk CRM 端点、读取或重放 Cookie/CSRF/反爬令牌、接收任意远程 selector/script,或使用模糊客户名和列表位置点击。允许 MAIN world 读取 OneTalk 页面已经加载的、经过固定白名单清洗的基础资料;Service Worker 和服务端不得重放页面 token 或自行拼接 CRM 请求。
- R38a. 基础资料只读取 `__conversationListData__` 初始快照和 `im-conversation-list:syncData` 更新;邮箱、注册时间、买家标签、详情微应用、DOM 刷新和群聊成员另行规划,不得以缺字段为由扩大本链路。
- R39. 本项目不设计普通消息删除、账号迁移历史搬运或 OneTalk 召回/编辑生命周期;`onetalk_message` 不提供常规物理删除路径。未来如需支持,必须单独规划 Bright 事实变化与 Mind 业务引用一致性。
- R40. 本项目不支持 `channelAccountId` 在 workspace 之间迁移,不迁移、复制或重新归属客户、负责人、摘要、未读或 Bright 消息。消息复用只适用于当前有效授权关系下的同一 channelAccountId。
- R41. 新架构首次写入 Bright 消息后,旧 OneTalk outbox/dispatch 路径永久保持禁用;故障处置只能暂停新链路、修复并恢复,不能回退旧事实链路。
@@ -221,9 +223,10 @@
- [ ] AC40. 全量切换后旧插件的 OneTalk 请求明确返回 `onetalk_protocol_upgrade_required`,不会进入旧 outbox;其它兼容渠道继续工作。
- [ ] AC41. Bright 技术会话同步进度能表示零消息会话、首次/增量/失败状态、锚点与最近观察时间,且不含消息正文或 Mind 业务字段。
- [ ] AC42. Mind 无法创建 Bright 尚未由 OneTalk 插件发现的会话;插件枚举并提交技术会话后,Mind 才能读取并建立业务关联。
- [ ] AC43. senderId/loginUserId 完整但 Mind 无对应资料时消息仍显示原始 ID/未关联占位;不会自动创建客户或触发资料补全。
- [ ] AC43. senderId/loginUserId 完整但 Mind 无对应资料时消息仍显示原始 ID/未关联占位;消息链路不会自动创建客户或触发详情资料补全。
- [ ] AC44. 插件离线时历史仍可读取、发送和实时接收禁用;插件重连后从服务端锚点恢复。
- [ ] AC45. Bright 消息与 Mind 业务接口能按确认的部分故障规则独立降级;Mind 认证视图不可用时 Bright 历史、连接、同步和发送全部拒绝。
- [ ] AC46. 首次有效 OneTalk 页面 hello 仅对当前已加载单聊产生 profile snapshot;新单聊和白名单资料变化产生独立 profile event,经现有 Bright WebSocket 和 binding/read 授权投递 MindBright 不保存 profile,任意 HTTP response 才 ACK,无 response 保留 pending。
## Out of Scope