hiwork-core 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,86 @@
1
1
  /**
2
- * `/hiwork-pms-todo` loopback RPC 的 Host 实现:PMS 个人待办的取数 + 勾完成/取消 + 凭据托管。
2
+ * `/hiwork-pms-todo` loopback RPC 的 Host 实现:PMS 个人待办的取数 + 勾完成,凭据链是
3
+ * **网关身份断言 → PMS 换 10 小时 JWT**(用户零输入)。
3
4
  *
4
- * 契约见 spec `docs/superpowers/specs/2026-09-17-pms-todo-integration-design.md`:
5
- * - §3 D2 —— 认证 v1 是**凭据托管**:用户在 HiWork 里填一次 PMS 账号密码,Host 用它换 PMS JWT
6
- * (10 小时),并把 refresh token(7 天)一并存下;凭据获取的全部逻辑收敛在本文件的
7
- * {@link PmsTodoStore.authorize} 里,v2 换 IAM SSO 时只替换它,UI 与 RPC 契约不动(§3 D3);
8
- * - §3 D4 / §4.2 —— 待办的三条现有接口(today / statistics / setStatus),未知枚举原样透传;
9
- * - §3 D7 —— **我的任务**:`todo_task` 之外的未完成项目任务(`GET /api/task/tasks`,`myTasks`)+ 完成任务
10
- * (`PUT /api/task/tasks/{id}/status`,`completeTask`)。为什么必须有它:PMS 只在**任务创建那一刻**
11
- * 生成待办,转派 / 走 SQL 建的历史任务没有待办——只读待办会让这些任务在面板上消失;
12
- * - §3 D6 —— **JWT、账号密码一律只存在于 Host 半边**:客户端只发端点调用,c1 拿不到任何凭据;
13
- * - §4.1 —— 八个端点(v1 的六个 + §3 D7 的两个)、`{ok,value}` / `{ok,error:{code,message}}` 信封、
14
- * **永不抛**、六个稳定错误码(多了 `FORBIDDEN`,见 §3 D7 的权限提示要求);
15
- * - §4.4 —— 凭据 ref `HIWORK_PMS_CREDENTIALS`,**只在 `remember` 为真时持久化**;
16
- * - §7 —— 凭据失效走 `UNAUTHORIZED` 且**不自动重试**(避免用错密码连续触发 PMS 失败锁定)。
5
+ * 契约见 spec `docs/superpowers/specs/2026-09-21-pms-zero-input-login-design.md`(本文件取代
6
+ * `2026-09-17-pms-todo-integration-design.md` 的 §3 D2 / §3 D3 与 §4.4 口径):
7
+ * - §3 D1 —— 桌面端不再是「填一次 PMS 账号密码」,而是从网关分配清单里取出 `pms` 那条,
8
+ * 签一张 **120 秒**身份断言,打分配的 mint 端点换一张 **10 小时** PMS JWT;
9
+ * - §3 D9 —— 分配**按 slug 精确匹配 `pms`** 取出(清单里可能还有 visio-mcp 等其它服务),
10
+ * 绝不取「第一条断言分配」:顺序必须来自我们写死的常量,不来自清单返回顺序;
11
+ * - §3 D4 / §4.5 —— 令牌**完全不落盘**(只在进程内存里活,重启后静默重换);账号密码通道整体
12
+ * 删除(`login` / `logout` 端点、凭据记录、凭据读写路径一律不再有);首启把 v1 落在
13
+ * credentials 里的明文密码抹掉一次({@link clearLegacyCredentials});
14
+ * - §3 D8 —— 失败语义分两类:网关里没有 `pms` 分配 → `NOT_CONFIGURED`(找管理员);网关会话失效 /
15
+ * mint 被拒 → `UNAUTHORIZED`(动作在 HiWork,不在 PMS);PMS 或网关不可达 → `UPSTREAM_UNAVAILABLE`;
16
+ * - §4.4 —— `status` 的字段名沿用 v1(把跨仓扰动压到最小)但语义重定义:`configured` = 网关里存在
17
+ * `slug='pms'` 的分配,`loggedIn` = 手上有一张未过期的 JWT;新增 `reason`(`no-assignment` /
18
+ * `gateway-signed-out` / `unavailable`)供面板选文案;
19
+ * - §4.3 —— 端点白名单**六项**(v1 的八项减去 `login` / `logout`,顺序即字面量);
20
+ * - §6 与 plan 全局约束 2 —— mint 的 **4xx 不自动重试**(403 是「断言无效」,重打只会连打);
21
+ * 网络 / 5xx 才**换一张新断言**重试一次(断言是一次性的,重发同一张只会换来第二个 403);
22
+ * - §3 D7 —— `myTasks` 的 `assigneeId` 取 mint 响应里的 `userId`(**不由客户端传**,也不解析 JWT);
23
+ * - §7 —— 断言与令牌**不进日志、不进任何 RPC 响应**:日志里只有事件、状态码与 assignmentId。
17
24
  *
18
25
  * 与 `/hiwork-workbench`、`/hiwork-managed-*` 那几条链的关系:信封、端点白名单、错误码收敛、
19
- * 「永不抛」的形状沿用同一副骨架(`src/workbench-bridge.ts`),但这一条**多了一个别人没有的
20
- * 东西**——凭据生命周期(存什么、什么时候写盘、什么时候 refresh、什么时候要求人重新输入)。
21
- * 那也是本文件唯一复杂的部分,其余都是投影与校验。
22
- *
23
- * 三条铁律:
24
- * - **token 只在 Host 内存(+ 可选的加密凭据记录)**:本文件没有任何返回值带 token,日志里也
25
- * 不许出现 token / 密码 / refresh token(§7、§8 第 8 条由测试把守);
26
- * - **不自动重试登录**:见 {@link PmsTodoStore.login} 的注释——用错密码连打两次就可能触发 PMS 的
27
- * 失败锁定,而「自动重试」正是最常见的那种不小心连打;
28
- * - **失败不清空已有会话**:refresh 失败只把这一次调用判为 `UNAUTHORIZED`,不把凭据记录删掉
29
- * (用户可能是临时改密 / 网络抽风;删掉记录等于把「重新输入」变成「重新配置」)。
26
+ * 「永不抛」的形状沿用同一副骨架(`src/workbench-bridge.ts`),差别是本条链多了一段凭据获取
27
+ * (分配 → 断言 → 令牌)——它收敛在 {@link PmsTodoStore.authorize} **一处**,RPC 面与 UI 不感知
28
+ * 凭据形态(§3 D10)。
30
29
  *
31
30
  * 本模块不 import cordis 运行时,只依赖结构化的 {@link PmsTodoRpcDeps} 端口,便于单测直接伪造。
32
31
  */
33
- import { PmsError, PMS_FORBIDDEN_MESSAGE, PMS_GENERIC_FAILURE_MESSAGE, PMS_UNAUTHORIZED_MESSAGE, } from "./pms-client.js";
32
+ import { GatewayError, } from "./ai-router-client.js";
33
+ import { PmsError, PMS_FORBIDDEN_MESSAGE, PMS_GENERIC_FAILURE_MESSAGE, } from "./pms-client.js";
34
34
  /** RPC 频道;必须与 client 半边 `src/client/pms-todo.ts` 的 `PMS_TODO_CHANNEL` 一致(由测试锁定)。 */
35
35
  export const PMS_TODO_RPC_CHANNEL = '/hiwork-pms-todo';
36
36
  /**
37
- * 端点白名单(**顺序即字面量**,由测试钉死;v1 的六个 + §3 D7 追加的两个 = 八个)。
37
+ * 端点白名单(**顺序即字面量**,由测试钉死;v1 的八项减去 `login` / `logout` = 六项)。
38
+ *
39
+ * `status` 是能力状态,`today` / `statistics` / `setStatus` 是待办数据,`myTasks` / `completeTask`
40
+ * 是**项目任务**(§3 D7:待办之外的未完成任务,以及完成它)。
38
41
  *
39
- * `status` / `login` / `logout` 是凭据生命周期,`today` / `statistics` / `setStatus` 是待办数据,
40
- * `myTasks` / `completeTask` 是**项目任务**(§3 D7:待办之外的未完成任务,以及完成它)。
42
+ * ⚠️ 六项与 v1 的八项**刻意不同**——那是版本闸口:老 client 拿八项去比对新 Host 会对不上,于是走
43
+ * 「请升级 HiWork 客户端」的降级,而不是带着一个已经删掉的账号密码表单硬跑(spec §4.3)。
41
44
  * 白名单是客户端可用的**能力边界**:v1 不做新建 / 编辑 / 删除待办(§5.3),所以这里也没有
42
45
  * `create` / `update` / `remove`——别为了「一次写完」提前登记一个没有实现的承诺。
43
- *
44
- * ⚠️ 两个新端点是**追加**在末尾的:client 半边(`src/client/pms-todo.ts`)的常量与这里逐字相等,
45
- * 老 client 拿一个只有六项的清单去比对新 Host 的八项会不等——那是**有意的**(版本闸口),
46
- * 消费方按可选成员降级,见 `PmsTodoFace.myTasks` 的注释。
47
46
  */
48
47
  export const PMS_TODO_RPC_ENDPOINTS = [
49
- 'status', 'login', 'logout', 'today', 'statistics', 'setStatus', 'myTasks', 'completeTask',
48
+ 'status', 'today', 'statistics', 'setStatus', 'myTasks', 'completeTask',
50
49
  ];
51
50
  /**
52
- * 凭据 ref(spec §4.4,credentials 服务,加密落盘)。
53
- *
54
- * 值是一段 JSON:`{username, password?, refreshToken?, expiresAt?}`。**只有 `remember` 为真时
55
- * 才写这个 ref**;不记住时凭据只活在进程内存里。别把它挪进 settings —— settings 是明文 YAML,
56
- * 而且会出现在设置页快照里(§7 第一条)。
51
+ * 分配匹配的**唯一**依据(spec §3 D9):`GET /api/me/mcp-assignments` 里 `slug` 逐字等于它的那条。
52
+ * 它就是断言的 `aud`(网关侧注释:`aud` = 目标服务的 slug),也是网关连接器的 id。
53
+ */
54
+ export const PMS_ASSIGNMENT_SLUG = 'pms';
55
+ /**
56
+ * v1 遗留的凭据 ref(spec §4.5 / D4)。**本文件不再读写它**——值只被
57
+ * {@link clearLegacyCredentials} 用来做首启的一次性清理;随后它就从配置面消失了。
57
58
  */
58
59
  export const PMS_CREDENTIALS_REF = 'HIWORK_PMS_CREDENTIALS';
59
60
  /**
60
- * 凭据记录里 `expiresAt` 缺失时的兜底有效期:10 小时(与 `src/pms-client.ts` 同值)。
61
- * 正常路径一律写服务端给的 `expiresIn` 算出来的绝对时刻。
61
+ * 令牌的**兜底**有效期:10 小时(= PMS `jwt.expiration=36000000`)。
62
+ * 正常路径一律用 mint 响应给的 `expiresAt`;只有它解析不了时才用它兜底(见 `sessionOf`)。
62
63
  */
63
64
  export const PMS_TODO_ACCESS_TOKEN_TTL_MS = 10 * 60 * 60 * 1000;
64
- /** 从未配置凭据的文案(UI 据此渲染引导卡片,不做静默失败)。 */
65
- export const PMS_TODO_NOT_CONFIGURED_MESSAGE = '尚未配置 PMS 账号,请先输入账号密码';
66
- /** 凭据失效的文案(UI 据此要求重新输入,且保留上次数据可见)。 */
67
- export const PMS_TODO_UNAUTHORIZED_MESSAGE = PMS_UNAUTHORIZED_MESSAGE;
65
+ /**
66
+ * 令牌复用口径(spec §4.5):剩余寿命不低于原寿命这个比例时复用,否则重换。
67
+ *
68
+ * 值与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `REUSE_MIN_REMAINING_RATIO` 相同
69
+ * ——那是受管连接器的同一条通道,两处口径必须一致(跨仓不能 import,所以值在这里复写一份并说明)。
70
+ * 一半是权衡:太低(5%)意味着下一次取数很可能正好撞在过期瞬间;太高(95%)等于每次取数都白换一次。
71
+ */
72
+ export const PMS_TODO_REUSE_MIN_REMAINING_RATIO = 0.5;
73
+ /**
74
+ * 网关里没有 `pms` 分配时的文案(spec §6 第一行:纯提示「联系管理员」,**不给任何手工入口**)。
75
+ */
76
+ export const PMS_TODO_NOT_CONFIGURED_MESSAGE = '管理员尚未把 PMS 接入这台客户端,请联系管理员';
77
+ /**
78
+ * 网关会话失效 / mint 被拒(403)的文案:动作在 **HiWork**,不是在 PMS(spec §3 D8)。
79
+ *
80
+ * ⚠️ 不再复用 `pms-client.ts` 的 `PMS_UNAUTHORIZED_MESSAGE`:那句还写着「请重新输入 PMS 账号密码」,
81
+ * 而密码通道正是本次要拆掉的东西——把它留给 PMS 网页端,桌面端一律引导重新登录 HiWork 客户端。
82
+ */
83
+ export const PMS_TODO_UNAUTHORIZED_MESSAGE = '登录已过期,请重新登录 HiWork 客户端';
68
84
  /**
69
85
  * 权限不足的文案(spec §3 D7 逐字要求;`PmsError` 的 `FORBIDDEN` 原样抬到 RPC 面)。
70
86
  * UI 必须明说它,而不是静默失败或回滚得莫名其妙。
@@ -87,10 +103,29 @@ export class PmsTodoRequestError extends Error {
87
103
  this.name = 'PmsTodoRequestError';
88
104
  }
89
105
  }
106
+ /**
107
+ * 本频道内部的凭据 / 会话错误(`NOT_CONFIGURED` / `UNAUTHORIZED` / `UPSTREAM_UNAVAILABLE`)。
108
+ *
109
+ * 用一个枚举样式的小类比裸字符串好:`PmsError` 只覆盖「上游说了什么」,而「没有 pms 分配」/
110
+ * 「没有网关会话」/「网关地址都没配」是 Host 自己的结论,两者混在一个类型里会让 `pmsTodoErrorOf`
111
+ * 不得不用字符串猜。
112
+ */
113
+ export class PmsTodoError extends Error {
114
+ code;
115
+ constructor(code, message) {
116
+ super(message);
117
+ this.code = code;
118
+ this.name = 'PmsTodoError';
119
+ }
120
+ }
90
121
  /** 是否为白名单端点。 */
91
122
  export function isPmsTodoRpcEndpoint(endpoint) {
92
123
  return PMS_TODO_RPC_ENDPOINTS.includes(endpoint);
93
124
  }
125
+ /** 非空字符串或 undefined(一个字段坏了不该让整条链路失败)。 */
126
+ function textOf(value) {
127
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
128
+ }
94
129
  /** 入参必须是对象(缺省 = 空对象)。风格照 `parseWorkbenchPayload`。 */
95
130
  function asRecord(endpoint, raw) {
96
131
  if (raw === undefined || raw === null)
@@ -120,32 +155,16 @@ function rejectUnknownKeys(endpoint, record, allowed) {
120
155
  * 而 `PENDING` / `COMPLETED` 是 PMS 侧唯一接受的两个值(其余的传过去只会得到一次业务失败)。
121
156
  * `completeTask` 的 `id` 同理(会被拼进**路径**,更要先收敛成正整数)。
122
157
  *
123
- * `myTasks` **不接受任何入参**(尤其不接受 `userId`):查询的「谁」必须由 Host 从凭据记录里取,
158
+ * `myTasks` **不接受任何入参**(尤其不接受 `userId`):查询的「谁」必须由 Host 从 mint 响应里取,
124
159
  * 否则客户端就能拿任意的 assigneeId 去查别人的任务——那正是 `PMS_TODO_RPC_ENDPOINTS` 白名单
125
160
  * 想守住的能力边界(§3 D6 / §3 D7)。
126
161
  */
127
162
  export function parsePmsTodoPayload(endpoint, raw) {
128
163
  const record = asRecord(endpoint, raw);
129
- if (endpoint === 'status' || endpoint === 'logout' || endpoint === 'today' || endpoint === 'statistics' || endpoint === 'myTasks') {
164
+ if (endpoint === 'status' || endpoint === 'today' || endpoint === 'statistics' || endpoint === 'myTasks') {
130
165
  rejectUnknownKeys(endpoint, record, []);
131
166
  return { endpoint };
132
167
  }
133
- if (endpoint === 'login') {
134
- rejectUnknownKeys(endpoint, record, ['username', 'password', 'remember']);
135
- const username = typeof record['username'] === 'string' ? record['username'].trim() : '';
136
- if (username === '')
137
- throw new PmsTodoRequestError('PMS 登录请求缺少 username。');
138
- // 密码**不 trim**:前后空格可能是密码的一部分,改了就成了「密码错误」。
139
- const password = record['password'];
140
- if (typeof password !== 'string' || password === '') {
141
- throw new PmsTodoRequestError('PMS 登录请求缺少 password。');
142
- }
143
- const remember = record['remember'];
144
- if (remember !== undefined && typeof remember !== 'boolean') {
145
- throw new PmsTodoRequestError('PMS 登录请求的 remember 必须是布尔值。');
146
- }
147
- return { endpoint, username, password, remember: remember === true };
148
- }
149
168
  if (endpoint === 'completeTask') {
150
169
  rejectUnknownKeys(endpoint, record, ['id']);
151
170
  const id = record['id'];
@@ -181,7 +200,7 @@ export function pmsTodoErrorOf(error) {
181
200
  }
182
201
  if (error.code === 'FORBIDDEN') {
183
202
  // 权限不足是**自己的一个码**(不是上游故障、更不是凭据问题):UI 按 §3 D7 给出
184
- // 「在 HiWork 里没有完成任务的权限,请到 PMS 操作」,而不是让用户去改密码或重试。
203
+ // 「在 HiWork 里没有完成任务的权限,请到 PMS 操作」,而不是让用户重新登录或重试。
185
204
  return { code: 'FORBIDDEN', message: PMS_TODO_FORBIDDEN_MESSAGE, details: {} };
186
205
  }
187
206
  return { code: 'UPSTREAM_UNAVAILABLE', message: displayPmsTodoMessage(error.code, error.message), details: {} };
@@ -212,107 +231,72 @@ export function displayPmsTodoMessage(code, message) {
212
231
  return PMS_TODO_MESSAGE_FALLBACK[code] ?? PMS_TODO_GENERIC_FAILURE_MESSAGE;
213
232
  return text;
214
233
  }
215
- /**
216
- * 本频道内部的凭据/会话错误(`NOT_CONFIGURED` / `UNAUTHORIZED`)。
217
- *
218
- * 用一个枚举样式的小类比裸字符串好:`PmsError` 只覆盖「上游说了什么」,而「本地判断出来
219
- * 没有凭据 / 没有 refresh token」是 Host 自己的结论,两者混在一个类型里会让 `pmsTodoErrorOf`
220
- * 不得不用字符串猜。
221
- */
222
- export class PmsTodoError extends Error {
223
- code;
224
- constructor(code, message) {
225
- super(message);
226
- this.code = code;
227
- this.name = 'PmsTodoError';
228
- }
229
- }
230
234
  /** 任意异常的名字(日志只记类型,不记 message)。 */
231
235
  function errorName(error) {
232
236
  return error instanceof Error ? error.name : typeof error;
233
237
  }
238
+ /** 网关异常的稳定 code(日志用;非 `GatewayError` 时退化成异常名)。 */
239
+ function gatewayCodeOf(error) {
240
+ return error instanceof GatewayError ? error.code : errorName(error);
241
+ }
234
242
  // ---------------------------------------------------------------------------
235
- // 凭据记录解析 / 序列化
243
+ // 首启清理(v1 遗留凭据)
236
244
  // ---------------------------------------------------------------------------
237
245
  /**
238
- * 解析凭据记录。
246
+ * 首启清理:抹掉 v1 落在 credentials 里的**明文 PMS 账号密码**(spec §3 D4 / §4.5)。
247
+ *
248
+ * 新链路没有任何凭据要落盘,所以这条记录之后就不该再存在——**幂等**(`resolve` 非空才 `unset`,
249
+ * 没有记录时一次写都不做)、**失败只记日志不抛**(清理失败绝不该拦住插件启动)。
250
+ * 调用方(`src/index.ts` 的 apply)只调一次;进程重启后再调也只会做一次没有记录的读。
239
251
  *
240
- * 记录是我们自己写的,但磁盘上的字节仍可能来自**旧版本的自己**,所以逐字段校验、
241
- * **未知字段忽略**(演进纪律),坏字段丢掉而不是让整条记录作废——一次 `format` 变更不该把用户
242
- * 变成「从未配置」。
252
+ * @param credentials - 凭据服务(只用到 `resolve` / `unset`)。
253
+ * @param log - Host 日志出口(只记事件与异常类型,不记 ref 里的值)。
243
254
  */
244
- export function parseCredentialsRecord(raw) {
245
- let parsed;
255
+ export async function clearLegacyCredentials(credentials, log) {
246
256
  try {
247
- parsed = JSON.parse(raw);
248
- }
249
- catch {
250
- return undefined;
251
- }
252
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
253
- return undefined;
254
- const record = parsed;
255
- const username = typeof record['username'] === 'string' ? record['username'].trim() : '';
256
- if (username === '')
257
- return undefined;
258
- const password = typeof record['password'] === 'string' && record['password'] !== '' ? record['password'] : undefined;
259
- const refreshToken = typeof record['refreshToken'] === 'string' && record['refreshToken'] !== ''
260
- ? record['refreshToken']
261
- : undefined;
262
- const expiresAt = typeof record['expiresAt'] === 'number' && Number.isFinite(record['expiresAt'])
263
- ? record['expiresAt']
264
- : undefined;
265
- // `userId` 必须是**安全整数**:它会被拼进 query(`assigneeId`),形态不对就当没有这个字段
266
- // ——`myTasks` 会据此引导用户重新登录一次,比拿一个脏 id 去查询好。
267
- const userId = typeof record['userId'] === 'number' && Number.isSafeInteger(record['userId'])
268
- ? record['userId']
269
- : undefined;
270
- return {
271
- username,
272
- ...(password === undefined ? {} : { password }),
273
- ...(refreshToken === undefined ? {} : { refreshToken }),
274
- ...(expiresAt === undefined ? {} : { expiresAt }),
275
- ...(userId === undefined ? {} : { userId }),
276
- };
277
- }
278
- /** 序列化凭据记录(**唯一**写盘路径)。缺字段不写键,避免把 `undefined` 变成 JSON 里的 `null`。 */
279
- export function serializeCredentialsRecord(record) {
280
- return JSON.stringify({
281
- username: record.username,
282
- ...(record.password === undefined ? {} : { password: record.password }),
283
- ...(record.refreshToken === undefined ? {} : { refreshToken: record.refreshToken }),
284
- ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }),
285
- ...(record.userId === undefined ? {} : { userId: record.userId }),
286
- });
257
+ const hit = await credentials.resolve(PMS_CREDENTIALS_REF);
258
+ if (hit === undefined || hit.value === '')
259
+ return;
260
+ await credentials.unset(PMS_CREDENTIALS_REF);
261
+ // 只记事件:磁盘上那份记录里有明文密码,任何值都不许进日志。
262
+ log?.('hiwork-core: 已清除 v1 遗留的 PMS 凭据记录');
263
+ }
264
+ catch (error) {
265
+ log?.(`hiwork-core: PMS 遗留凭据清除失败(${errorName(error)})`);
266
+ }
287
267
  }
288
268
  // ---------------------------------------------------------------------------
289
269
  // 会话与取数
290
270
  // ---------------------------------------------------------------------------
271
+ /** 一次 mint 结果的投影:把 ISO 串折成绝对时刻,并记下「什么时候换到的」。 */
272
+ function sessionOf(result, now) {
273
+ const parsed = Date.parse(result.expiresAt);
274
+ return {
275
+ token: result.token,
276
+ // `PmsClient` 已保证 `expiresAt` 可解析(缺失时它自己兜底成 now + 10 小时);这里再兜一次
277
+ // 是为了「上游回了一个解析不了的串」时不至于把过期时刻变成 NaN(那会让复用判据永远为假)。
278
+ expiresAt: Number.isFinite(parsed) ? parsed : now + PMS_TODO_ACCESS_TOKEN_TTL_MS,
279
+ issuedAt: now,
280
+ userId: result.userId,
281
+ username: result.username,
282
+ };
283
+ }
291
284
  /**
292
- * PMS 待办的状态仓库:持有凭据记录与 access token,按需续期,并暴露五个取数 / 命令动作
285
+ * PMS 待办的状态仓库:持有内存里的 PMS JWT,按分配 → 断言 → mint 拿令牌,并暴露五个取数 / 命令动作
293
286
  * (today / statistics / setStatus + §3 D7 的 myTasks / completeTask)。
294
287
  *
295
- * 一个刻意的分工:**凭据(账号密码 / refresh token)与 access token 只在本类里出现**。
296
- * 端点处理函数({@link handlePmsTodoEndpoint})只把返回值转发出去,因此「响应里没有 token」
297
- * 这件事是**结构上成立**的,而不是靠每一处返回值自觉。
288
+ * 一个刻意的分工:**断言与令牌只在本类里出现**。端点处理函数({@link handlePmsTodoEndpoint})
289
+ * 只把返回值转发出去,因此「响应里没有 token」这件事是**结构上成立**的,而不是靠每一处返回值自觉。
298
290
  */
299
291
  export class PmsTodoStore {
300
292
  deps;
301
- /** 内存里的凭据记录(不 remember 时它是这份凭据唯一的家)。 */
302
- record;
303
- /** 是否已经从 credentials 服务读过一次(避免每次调用都读盘)。 */
304
- loaded = false;
305
- /**
306
- * 当前会话是否**属于磁盘上的那条记录**(remember=true 的登录、或启动时从记录里恢复)。
307
- * 只有它为真时,refresh 换来的新 token 才回写记录——否则一次「不记住」的登录会顺手把
308
- * refresh token 写给下一个人(登出后磁盘上仍有可用凭据,这正是不 remember 的相反面)。
309
- */
310
- persisted = false;
311
- /** access token(**只在内存**)。 */
312
- access;
313
- /** 世代号:`login` / `logout` 各 +1,用来作废它之前发起的在飞请求(换账号的语义同工作台)。 */
314
- generation = 0;
315
- /** 在飞请求**连同世代与端点**记账:同一世代 + 同一端点才允许合并。 */
293
+ /** 内存里的会话(**唯一的家**,不落盘;重启后自然重换)。 */
294
+ session;
295
+ /** 网关分配清单里那条 `pms` 分配(只在内存里记住,免得每次取数都拉一遍清单)。 */
296
+ assignment;
297
+ /** 最近一次「为什么还没就绪」(进 `status` 的 `reason`;成功换到令牌时清空)。 */
298
+ reason;
299
+ /** 在飞请求按端点记账:同一端点才允许合并。 */
316
300
  inFlight;
317
301
  constructor(deps) {
318
302
  this.deps = deps;
@@ -320,76 +304,54 @@ export class PmsTodoStore {
320
304
  now() {
321
305
  return this.deps.now?.() ?? Date.now();
322
306
  }
323
- /** 当前状态(同步语义的取数,异步实现:可能读一次 credentials)。**永不抛**。 */
324
- async status() {
325
- const record = await this.currentRecord();
326
- return this.statusOf(record);
327
- }
328
307
  /**
329
- * 用账号密码登录并**换取** JWT。
308
+ * 当前状态(spec §4.4)。**永不抛**:失败在快照里表现为 `configured=false` + `reason`。
330
309
  *
331
- * ⚠️ **绝不自动重试**(spec §7 的硬要求):PMS 对连续失败登录有锁定策略,而「自动重试」正是
332
- * 最容易不小心连打两次的地方——用户手一抖打错一位密码,第二次尝试就会把账号推向锁定。
333
- * 所以本方法**只发一次**请求,失败即返回错误(UI 引导用户自己再输一次)。
310
+ * 顺序是刻意的:
311
+ * 1. 手上有一张未过期的 JWT → 直接报 `loggedIn`,**一次网络都不发**(面板每次挂载都读它);
312
+ * 2. 没有 → 问一次网关「到底有没有 `pms` 分配」。`configured` 的判据只能是它:用户填没填过
313
+ * 账号密码这件事已经不存在了(D3),而「有没有分配」只有网关知道。第二次起走内存里的那条
314
+ * 分配,不再打网关。
334
315
  *
335
- * `remember` 为真才落盘(§4.4)。落盘写的是**完整**记录(含密码),因为下次启动时要靠它
336
- * 继续换 token——这不是「记住密码」的 UI 便利,而是 v1 认证方案本身(§3 D2)。
316
+ * `reason` 只在「还没就绪」时出现:`no-assignment` / `gateway-signed-out` / `unavailable` 三种,
317
+ * 分别对应「找管理员」/「重新登录 HiWork」/「稍后重试」(spec §3 D8 的 UI 动作各不相同)。
337
318
  */
338
- async login(input) {
339
- const tokens = await this.deps.pms.login(input.username, input.password);
340
- const record = {
341
- username: input.username,
342
- password: input.password,
343
- ...(tokens.refreshToken === undefined ? {} : { refreshToken: tokens.refreshToken }),
344
- expiresAt: tokens.expiresAt,
345
- // §3 D7:登录响应里的 `userId` 必须**在这里**捕获——「我的任务」要用它当 `assigneeId`,
346
- // 而除了登录/刷新没有别的地方能拿到它(不猜、不按用户名反查)。
347
- ...(tokens.userId === undefined ? {} : { userId: tokens.userId }),
348
- };
349
- // 换账号:账上还在飞的请求属于上一个人,作废掉。
350
- this.generation += 1;
351
- this.record = record;
352
- this.loaded = true;
353
- this.access = { token: tokens.accessToken, expiresAt: tokens.expiresAt };
354
- this.persisted = input.remember;
355
- if (input.remember) {
356
- await this.persist(record);
319
+ async status() {
320
+ const session = this.session;
321
+ if (session !== undefined && this.now() < session.expiresAt) {
322
+ return {
323
+ configured: true,
324
+ loggedIn: true,
325
+ username: session.username,
326
+ expiresAt: new Date(session.expiresAt).toISOString(),
327
+ userId: session.userId,
328
+ ...(this.reason === undefined ? {} : { reason: this.reason }),
329
+ };
357
330
  }
358
- else {
359
- await this.forgetStored();
331
+ try {
332
+ const cookie = await this.sessionCookie();
333
+ const gateway = this.gatewayPort();
334
+ await this.ensureAssignment(gateway, cookie);
335
+ return { configured: true, loggedIn: false, ...(this.reason === undefined ? {} : { reason: this.reason }) };
336
+ }
337
+ catch {
338
+ // 原因已经由失败那一层记下(`fail`):这里只把它投影出来,不重新判断一次。
339
+ return { configured: false, loggedIn: false, reason: this.reason ?? 'unavailable' };
360
340
  }
361
- this.deps.log?.('hiwork-core: PMS 账号已登录(凭据只存于 Host)');
362
- return this.statusOf(record);
363
- }
364
- /**
365
- * 登出:清内存 + 清凭据记录(spec §4.1 的 `logout`)。
366
- *
367
- * 不清 PMS 侧的服务端会话:PMS 的 JWT 是无状态签发的(没有「撤销单个 token」的接口),
368
- * 我们能做且该做的是**不再持有**它——删掉本地凭据后,下一次取数一定要求重新输入。
369
- */
370
- async logout() {
371
- this.generation += 1;
372
- this.record = undefined;
373
- this.loaded = true;
374
- this.persisted = false;
375
- this.access = undefined;
376
- await this.forgetStored();
377
- this.deps.log?.('hiwork-core: PMS 凭据已清除');
378
- return this.statusOf(undefined);
379
341
  }
380
342
  /** `GET /api/todo/today` + 本次拉取时刻。同一时刻的并发调用合并成一次 PMS 请求。 */
381
343
  async today() {
382
344
  return await this.dedupe('today', async () => {
383
- const token = await this.authorize();
384
- const items = await this.deps.pms.today(token);
345
+ const session = await this.authorize();
346
+ const items = await this.deps.pms.today(session.token);
385
347
  return { items, fetchedAt: new Date(this.now()).toISOString() };
386
348
  });
387
349
  }
388
350
  /** `GET /api/todo/statistics`。并发调用同样合并。 */
389
351
  async statistics() {
390
352
  return await this.dedupe('statistics', async () => {
391
- const token = await this.authorize();
392
- return await this.deps.pms.statistics(token);
353
+ const session = await this.authorize();
354
+ return await this.deps.pms.statistics(session.token);
393
355
  });
394
356
  }
395
357
  /**
@@ -400,31 +362,21 @@ export class PmsTodoStore {
400
362
  * 的顺序处理」——那个语义 PMS 也不提供。
401
363
  */
402
364
  async setStatus(input) {
403
- const token = await this.authorize();
404
- return await this.deps.pms.setStatus(token, input.id, input.status);
365
+ const session = await this.authorize();
366
+ return await this.deps.pms.setStatus(session.token, input.id, input.status);
405
367
  }
406
368
  /**
407
369
  * `GET /api/task/tasks`:**我的未完成项目任务**(spec §3 D7)。
408
370
  *
409
- * `userId` 只从凭据记录里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
371
+ * `userId` 只从 mint 响应里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
410
372
  * 「只能查自己」唯一可靠的落点:端点白名单里也没有任何「按 id 查别人」的形状。
411
373
  *
412
- * 记录里**没有** `userId`(2026-09-18 之前登录的旧记录)时收敛成 `UNAUTHORIZED`:用户重新登录
413
- * 一次即可拿到它。这里刻意**不**回退成「按用户名去查用户列表」之类的动作——那需要额外的接口与
414
- * 权限,而且会在 Host 里凭空多一条拿别人数据的路径。
415
- *
416
374
  * 并发调用合并(同 `today`):这是**取数**,同一时刻问两遍没有意义。
417
375
  */
418
376
  async myTasks(opts = {}) {
419
377
  return await this.dedupe('myTasks', async () => {
420
- const token = await this.authorize();
421
- const userId = (await this.currentRecord())?.userId;
422
- if (userId === undefined) {
423
- // 只记事件(账号名都不记):这是一次本地结论,用户重新登录就能拿到 userId。
424
- this.deps.log?.('hiwork-core: PMS 凭据记录里没有 userId,我的任务需要重新登录一次');
425
- throw new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
426
- }
427
- return await this.deps.pms.myTasks(token, userId, opts);
378
+ const session = await this.authorize();
379
+ return await this.deps.pms.myTasks(session.token, session.userId, opts);
428
380
  });
429
381
  }
430
382
  /**
@@ -437,148 +389,219 @@ export class PmsTodoStore {
437
389
  * UI 按 §3 D7 提示「请到 PMS 操作」——**不是**重新登录。
438
390
  */
439
391
  async completeTask(input) {
440
- const token = await this.authorize();
441
- return await this.deps.pms.completeTask(token, input.id);
392
+ const session = await this.authorize();
393
+ return await this.deps.pms.completeTask(session.token, input.id);
442
394
  }
443
395
  /**
444
- * 取一个能用的 access token(凭证获取的唯一入口,spec §3 D3 说 v2 只替换这里)。
396
+ * 取一个能用的 PMS JWT(凭据获取的**唯一入口**,spec §3 D10)。
445
397
  *
446
398
  * 顺序是刻意的:
447
- * 1. 内存里有未过期的 access token → 直接用(零网络请求);
448
- * 2. 过期了 → 先试 `refresh(refreshToken)`(§D2:refresh 有效就完全不必打扰用户);
449
- * refresh 成功会**顺手**把新的 access / refresh token 写回内存(原来是持久化的会话则一并落盘);
450
- * 3. refresh 失败(401 / 记录里根本没有 refresh token)→ `UNAUTHORIZED`,UI 要求重新输入。
399
+ * 1. 内存里有一张「剩余寿命 ≥ 50%」的令牌 → 直接用(零网络请求);
400
+ * 2. 否则:解析网关会话 → 从分配清单里取 `slug='pms'` 那条(D9)→ 签一张 120 秒断言 →
401
+ * 打分配的 mint 端点换一张 10 小时 JWT → 存回内存({@link PmsTodoStore.mint})。
451
402
  *
452
- * ⚠️ 这里**没有**「拿存下来的密码重新登录」这一支,而且是刻意的:§7 要求凭据失效时
453
- * **不自动重试**——用存下来的密码自动重登 = 每次 token 过期都自动替用户登一次,密码一旦改成
454
- * 别的,就会变成一条稳定的「定时用错密码打 PMS」的流水,直到账号被锁。密码只在用户**明确**
455
- * 提交一次 `login` 时才用(那时它也确实是用户刚输入的)。
403
+ * ⚠️ **断言绝不缓存**:它只有 120 秒、且 PMS 侧拿 `jti` 做一次性防重放,缓存下来只会是一张废纸。
404
+ * ⚠️ 这里**没有**「拿存下来的账号密码重新登一次」这一支,而且是结构上没有:账号密码通道已删
405
+ * (spec §3 D3),本文件里也搜不到任何密码字段(§7 的验收项)。
456
406
  */
457
407
  async authorize() {
458
- const access = this.access;
459
- if (access !== undefined && this.now() < access.expiresAt)
460
- return access.token;
461
- const record = await this.currentRecord();
462
- if (record === undefined) {
463
- throw new PmsTodoError('NOT_CONFIGURED', PMS_TODO_NOT_CONFIGURED_MESSAGE);
464
- }
465
- const refreshToken = record.refreshToken;
466
- if (refreshToken === undefined) {
467
- throw new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
468
- }
469
- const tokens = await this.deps.pms.refresh(refreshToken);
470
- // ⚠️ `userId` **不从刷新响应里取**:刷新换的是 token,不改变「我是谁」(§3 D7)。
471
- // `...record` 用来把它(以及用户名 / 密码)原样带过去。
472
- const next = {
473
- ...record,
474
- ...(tokens.refreshToken === undefined ? {} : { refreshToken: tokens.refreshToken }),
475
- expiresAt: tokens.expiresAt,
476
- };
477
- this.record = next;
478
- this.access = { token: tokens.accessToken, expiresAt: tokens.expiresAt };
479
- if (this.persisted)
480
- await this.persist(next);
481
- return tokens.accessToken;
408
+ const cached = this.session;
409
+ if (cached !== undefined && this.reusable(cached))
410
+ return cached;
411
+ const cookie = await this.sessionCookie();
412
+ const gateway = this.gatewayPort();
413
+ const assignment = await this.ensureAssignment(gateway, cookie);
414
+ return await this.mint(gateway, cookie, assignment);
482
415
  }
483
416
  /**
484
- * 状态投影(不读盘、不发网络)。
417
+ * 这张令牌还能不能接着用(spec §4.5:剩余寿命 ≥ 50% 才复用)。
485
418
  *
486
- * `expiresAt` **只报还没过期的那个时刻**:记录里那份可能是上一次会话留下的(进程重启后内存里
487
- * 没有 access token,只有磁盘上的旧 `expiresAt`),把它照实报出去,界面就会显示一个已经过去的
488
- * 「有效期至」,看着像还能用。过期了就不写这个键——refresh 会顺手把它换成新的。
419
+ * 判据与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `isCredentialReusable` 同口径:
420
+ * 分母是**这张令牌自己的寿命**(`expiresAt - issuedAt`),不是某个写死的 10 小时——服务端将来
421
+ * 改短有效期,复用口径跟着它走。
489
422
  */
490
- statusOf(record) {
423
+ reusable(session) {
491
424
  const now = this.now();
492
- const access = this.access;
493
- const hasAccess = access !== undefined && now < access.expiresAt;
494
- const hasRefresh = record?.refreshToken !== undefined && record.refreshToken !== '';
495
- const expiresAt = access?.expiresAt ?? record?.expiresAt;
496
- const freshExpiry = expiresAt !== undefined && expiresAt > now ? expiresAt : undefined;
497
- return {
498
- configured: record !== undefined,
499
- loggedIn: hasAccess || hasRefresh,
500
- ...(record === undefined ? {} : { username: record.username }),
501
- ...(freshExpiry === undefined ? {} : { expiresAt: new Date(freshExpiry).toISOString() }),
502
- };
425
+ if (now >= session.expiresAt)
426
+ return false;
427
+ const lifetime = session.expiresAt - session.issuedAt;
428
+ if (lifetime <= 0)
429
+ return false;
430
+ return session.expiresAt - now >= lifetime * PMS_TODO_REUSE_MIN_REMAINING_RATIO;
431
+ }
432
+ /** 网关会话 cookie;缺失即 `UNAUTHORIZED`(动作在 HiWork:重新登录客户端)。 */
433
+ async sessionCookie() {
434
+ const cookie = await this.deps.resolveSession();
435
+ if (cookie === undefined || cookie === '') {
436
+ this.fail('gateway-signed-out');
437
+ throw new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
438
+ }
439
+ return cookie;
440
+ }
441
+ /** 当前 baseURL 对应的网关客户端;没配置 baseURL 时同样是「去 HiWork 登录」这一类动作。 */
442
+ gatewayPort() {
443
+ const gateway = this.deps.gateway();
444
+ if (gateway === undefined) {
445
+ this.fail('gateway-signed-out');
446
+ throw new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
447
+ }
448
+ return gateway;
503
449
  }
504
450
  /**
505
- * 当前凭据记录:内存优先,首次调用时从 credentials 服务读一次。
451
+ * 取出这条客户端**被分配**的 `pms` 服务(spec §3 D9)。
452
+ *
453
+ * 匹配依据只有 slug(逐字等于 {@link PMS_ASSIGNMENT_SLUG}):清单里还有 visio-mcp 之类的分配,
454
+ * 「取第一条断言分配」在分配多起来之后会随机串台。**不看 `auth.kind`**:那是服务端演进的开放
455
+ * 集合,我们只认自己写死的 slug(D9 的原文就是「唯一匹配依据」)。
506
456
  *
507
- * 「读一次就记住 `loaded`」是有意的:凭据的写入方只有本类(`login` / `logout` / refresh 回写),
508
- * 因此在同一进程里,读盘只需要一次。跨进程变更(别的进程写了同一个 ref)本就不可观测,
509
- * 硬要每次重新读只会让「不 remember 的登录」被磁盘上的旧记录当场覆盖。
457
+ * 没有这条分配 → `NOT_CONFIGURED`:UI 提示「找管理员」,**不给任何手工配置入口**(spec §6)。
510
458
  */
511
- async currentRecord() {
512
- if (this.loaded)
513
- return this.record;
514
- this.loaded = true;
459
+ async ensureAssignment(gateway, cookie) {
460
+ const cached = this.assignment;
461
+ if (cached !== undefined)
462
+ return cached;
463
+ let view;
515
464
  try {
516
- const hit = await this.deps.credentials.resolve(PMS_CREDENTIALS_REF);
517
- if (hit === undefined || hit.value === '')
518
- return undefined;
519
- const record = parseCredentialsRecord(hit.value);
520
- // 从记录里恢复 = 这是一个 remember 过的会话:refresh 的新 token 要跟着回写。
521
- this.record = record;
522
- this.persisted = record !== undefined;
523
- return record;
465
+ view = await gateway.getMyMcpAssignments(cookie);
524
466
  }
525
467
  catch (error) {
526
- this.deps.log?.(`hiwork-core: PMS 凭据读取失败(${errorName(error)})`);
527
- return undefined;
468
+ throw this.gatewayFailure(error);
469
+ }
470
+ const list = Array.isArray(view?.assignments) ? view.assignments : [];
471
+ for (const item of list) {
472
+ if (item?.slug !== PMS_ASSIGNMENT_SLUG)
473
+ continue;
474
+ const assignmentId = textOf(item.assignmentId);
475
+ if (assignmentId === undefined)
476
+ continue;
477
+ const assignment = { assignmentId };
478
+ this.assignment = assignment;
479
+ this.reason = undefined;
480
+ return assignment;
528
481
  }
482
+ this.fail('no-assignment');
483
+ throw new PmsTodoError('NOT_CONFIGURED', PMS_TODO_NOT_CONFIGURED_MESSAGE);
529
484
  }
530
485
  /**
531
- * 落盘(**唯一**写 credentials 的地方,只在 remember 为真时被调用)。
486
+ * 签一张断言(120 秒、一次性),并取出它的 `mintUrl`。
532
487
  *
533
- * 失败**不**把一次成功的登录判为失败:会话已经拿到了,内存里照常可用;密码存不下的后果只是
534
- * 「重启后要重新输入」。反过来把登录判为失败会让用户以为密码错了。
488
+ * `mintUrl` 由**门票**给(`auth.mintUrl` 的权威副本在网关侧),本层不猜也不改写它。
535
489
  */
536
- async persist(record) {
490
+ async assertionTicket(gateway, cookie, assignment) {
491
+ let ticket;
537
492
  try {
538
- await this.deps.credentials.set(PMS_CREDENTIALS_REF, serializeCredentialsRecord(record));
539
- this.persisted = true;
493
+ ticket = await gateway.issueMyMcpAssertion(cookie, assignment.assignmentId);
540
494
  }
541
495
  catch (error) {
542
- this.deps.log?.(`hiwork-core: PMS 凭据落盘失败(${errorName(error)})`);
496
+ throw this.gatewayFailure(error);
543
497
  }
498
+ const assertion = textOf(ticket?.assertion);
499
+ const mintUrl = textOf(ticket?.mintUrl);
500
+ if (assertion === undefined || mintUrl === undefined) {
501
+ // 形状不符的票据不值得当成成功(与 `managed-mcp.ts` 同款判据)。
502
+ this.deps.log?.('hiwork-core: PMS 取断言收到无效响应(缺 assertion / mintUrl)');
503
+ this.fail('unavailable');
504
+ throw new PmsTodoError('INTERNAL', PMS_TODO_GENERIC_FAILURE_MESSAGE);
505
+ }
506
+ return { assertion, mintUrl };
544
507
  }
545
508
  /**
546
- * 抹掉磁盘上的记录(logout / 不记住的登录)。
509
+ * 断言 → 令牌(spec §4.1b 的客户端侧),失败语义按 §6 / plan 全局约束 2 分两类:
510
+ *
511
+ * - **被拒(401 / 403)→ 不重试**:断言过期 / `aud` 不符 / 签名不过 / 工号在 PMS 不存在,
512
+ * 四种原因在 PMS 侧都是同一个 403(防账号探测),重发只会连打;动作是「重新登录 HiWork」。
513
+ * - **网络 / 5xx → 换一张新断言重试一次**:断言可能是在途耗掉的(120 秒的门票),而同一张断言
514
+ * 是一次性的(`jti` 防重放),所以重试必须重新签一张。仍失败 → `UPSTREAM_UNAVAILABLE`。
547
515
  *
548
- * 先 `resolve` 一次再 `unset`:没有记录时一次写都不做(测试与磁盘都清清爽爽),有记录时
549
- * **必须**清掉——否则一条旧的 account+refresh token 会在下次启动时被当成「这个人配置过凭据」,
550
- * 用户明明刚选了「不记住」却在重启后仍是登录态。
516
+ * 日志只记 assignmentId 与事件(**不记断言、不记令牌**,spec §7)。
551
517
  */
552
- async forgetStored() {
518
+ async mint(gateway, cookie, assignment) {
519
+ const deviceRef = await this.deviceRef();
520
+ let attempt = 0;
521
+ while (attempt < 2) {
522
+ attempt += 1;
523
+ const ticket = await this.assertionTicket(gateway, cookie, assignment);
524
+ try {
525
+ const session = sessionOf(await this.deps.pms.exchangeAssertion(ticket.mintUrl, ticket.assertion, deviceRef), this.now());
526
+ this.session = session;
527
+ this.reason = undefined;
528
+ return session;
529
+ }
530
+ catch (error) {
531
+ if (error instanceof PmsError && error.code === 'UNAUTHORIZED') {
532
+ this.deps.log?.(`hiwork-core: PMS 换令牌被拒(分配 ${assignment.assignmentId})`);
533
+ this.fail('gateway-signed-out');
534
+ throw new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
535
+ }
536
+ if (error instanceof PmsError && error.code === 'UPSTREAM_UNAVAILABLE' && attempt === 1) {
537
+ this.deps.log?.(`hiwork-core: PMS 换令牌失败(分配 ${assignment.assignmentId}),换一张新断言重试一次`);
538
+ continue;
539
+ }
540
+ // 重试后仍失败 / 非预期异常:收敛成稳定错误,原始 message(可能含内网地址)不外泄。
541
+ this.fail('unavailable');
542
+ throw error instanceof PmsError ? error : new PmsTodoError('INTERNAL', PMS_TODO_GENERIC_FAILURE_MESSAGE);
543
+ }
544
+ }
545
+ this.fail('unavailable');
546
+ throw new PmsTodoError('UPSTREAM_UNAVAILABLE', PMS_TODO_GENERIC_FAILURE_MESSAGE);
547
+ }
548
+ /** 设备标识(只进 PMS 侧审计、不参与鉴权):解析失败留空,不拦登录。 */
549
+ async deviceRef() {
553
550
  try {
554
- const hit = await this.deps.credentials.resolve(PMS_CREDENTIALS_REF);
555
- if (hit === undefined || hit.value === '')
556
- return;
557
- await this.deps.credentials.unset(PMS_CREDENTIALS_REF);
551
+ return await this.deps.resolveDeviceId();
558
552
  }
559
553
  catch (error) {
560
- this.deps.log?.(`hiwork-core: PMS 凭据清除失败(${errorName(error)})`);
554
+ this.deps.log?.(`hiwork-core: 设备标识解析失败(${errorName(error)}),deviceRef 留空`);
555
+ return '';
561
556
  }
562
557
  }
563
558
  /**
564
- * 并发合并:同一世代 + 同一端点的在飞请求共用一次调用与同一个返回值。
559
+ * 网关异常 → 本频道的稳定错误(spec §3 D8)。
560
+ *
561
+ * 关键的分法:**会话失效**与**分配被撤销/停用**要分开。前者是用户自己的动作(重新登录 HiWork),
562
+ * 后者是管理员那边的动作(重新分配)——归到同一句话,用户就会对着一个自己修不了的问题反复重试。
563
+ */
564
+ gatewayFailure(error) {
565
+ this.deps.log?.(`hiwork-core: PMS 网关调用失败(${gatewayCodeOf(error)})`);
566
+ if (error instanceof GatewayError) {
567
+ if (error.code === 'SESSION_EXPIRED') {
568
+ this.fail('gateway-signed-out');
569
+ return new PmsTodoError('UNAUTHORIZED', PMS_TODO_UNAUTHORIZED_MESSAGE);
570
+ }
571
+ if (error.code === 'MCP_ASSIGNMENT_GONE' || error.code === 'MCP_NOT_DELIVERABLE') {
572
+ // 分配在网关侧被撤销 / 停用(或改成了不可下发的凭据方式):与「没有这条分配」同一个动作面
573
+ // (找管理员),并且要把内存里那条分配丢掉——不丢的话每次取数都会重打一次注定 404 的断言。
574
+ this.assignment = undefined;
575
+ this.fail('no-assignment');
576
+ return new PmsTodoError('NOT_CONFIGURED', PMS_TODO_NOT_CONFIGURED_MESSAGE);
577
+ }
578
+ }
579
+ this.fail('unavailable');
580
+ return new PmsTodoError('UPSTREAM_UNAVAILABLE', PMS_TODO_GENERIC_FAILURE_MESSAGE);
581
+ }
582
+ /** 记下「为什么还没就绪」(只影响 `status` 的 `reason`,不影响错误码)。 */
583
+ fail(reason) {
584
+ this.reason = reason;
585
+ }
586
+ /**
587
+ * 并发合并:同一端点的在飞请求共用一次调用与同一个返回值(面板挂载与手动刷新常常同拍)。
565
588
  *
566
- * 世代判据与 `src/client/managed-workbench.ts` 的 `dedupe` 同形:`login` / `logout` 之后
567
- * 账上那条属于**上一个人**,谁都不许复用(复用等于把上一个人的待办显示给新账号)。
589
+ * v1 还带一个「世代号」(`login` / `logout` 之后账上那条属于上一个人);现在**身份只有一个来源**
590
+ * (网关会话),进程内没有换人的入口,所以按端点名合并即可。换账号的清理由客户端半边的
591
+ * `clear()`(镜像)与会话本身负责。
568
592
  */
569
593
  async dedupe(key, task) {
570
594
  const inFlight = this.inFlight;
571
- if (inFlight !== undefined && inFlight.generation === this.generation && inFlight.key === key) {
595
+ if (inFlight !== undefined && inFlight.key === key) {
572
596
  return await inFlight.task;
573
597
  }
574
- const generation = this.generation;
575
598
  const promise = task();
576
- this.inFlight = { generation, key, task: promise };
599
+ this.inFlight = { key, task: promise };
577
600
  try {
578
601
  return await promise;
579
602
  }
580
603
  finally {
581
- // 只有自己那次还在账上才清:登出后新发起的那条不该被这一笔收尾抹掉。
604
+ // 只有自己那次还在账上才清(后发起的那条不该被这一笔收尾抹掉)。
582
605
  if (this.inFlight?.task === promise)
583
606
  this.inFlight = undefined;
584
607
  }
@@ -591,8 +614,8 @@ export class PmsTodoStore {
591
614
  * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
592
615
  * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
593
616
  *
594
- * 日志纪律:只记**端点名 + 异常类型**。入参里有 `password`(`login`),返回值里有 access token 的
595
- * 派生状态,两者都不许进日志(§7)。
617
+ * 日志纪律:只记**端点名 + 异常类型**。返回值里有会话的派生状态(过期时刻 / 账号名 / userId),
618
+ * 令牌与断言本身一律不进日志(§7)。
596
619
  */
597
620
  export async function handlePmsTodoEndpoint(endpoint, rawPayload, deps, store, signal) {
598
621
  try {
@@ -605,10 +628,6 @@ export async function handlePmsTodoEndpoint(endpoint, rawPayload, deps, store, s
605
628
  switch (payload.endpoint) {
606
629
  case 'status':
607
630
  return { ok: true, value: await store.status() };
608
- case 'login':
609
- return { ok: true, value: await store.login({ username: payload.username, password: payload.password, remember: payload.remember }) };
610
- case 'logout':
611
- return { ok: true, value: await store.logout() };
612
631
  case 'today':
613
632
  return { ok: true, value: await store.today() };
614
633
  case 'statistics':