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.
- package/lib/client.js +21 -30
- package/lib/index.js +324 -274
- package/lib/pms-client.js +122 -59
- package/lib/pms-todo-bridge.js +332 -313
- package/lib/types/client/contracts.d.ts +10 -8
- package/lib/types/client/pms-todo.d.ts +67 -49
- package/lib/types/pms-client.d.ts +58 -34
- package/lib/types/pms-todo-bridge.d.ts +210 -184
- package/package.json +1 -1
package/lib/pms-todo-bridge.js
CHANGED
|
@@ -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-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - §3
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - §
|
|
14
|
-
*
|
|
15
|
-
* - §4.4 ——
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
37
|
+
* 端点白名单(**顺序即字面量**,由测试钉死;v1 的八项减去 `login` / `logout` = 六项)。
|
|
38
|
+
*
|
|
39
|
+
* `status` 是能力状态,`today` / `statistics` / `setStatus` 是待办数据,`myTasks` / `completeTask`
|
|
40
|
+
* 是**项目任务**(§3 D7:待办之外的未完成任务,以及完成它)。
|
|
38
41
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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', '
|
|
48
|
+
'status', 'today', 'statistics', 'setStatus', 'myTasks', 'completeTask',
|
|
50
49
|
];
|
|
51
50
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 === '
|
|
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
|
-
*
|
|
242
|
-
* 变成「从未配置」。
|
|
252
|
+
* @param credentials - 凭据服务(只用到 `resolve` / `unset`)。
|
|
253
|
+
* @param log - Host 日志出口(只记事件与异常类型,不记 ref 里的值)。
|
|
243
254
|
*/
|
|
244
|
-
export function
|
|
245
|
-
let parsed;
|
|
255
|
+
export async function clearLegacyCredentials(credentials, log) {
|
|
246
256
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
285
|
+
* PMS 待办的状态仓库:持有内存里的 PMS JWT,按分配 → 断言 → mint 拿令牌,并暴露五个取数 / 命令动作
|
|
293
286
|
* (today / statistics / setStatus + §3 D7 的 myTasks / completeTask)。
|
|
294
287
|
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* 这件事是**结构上成立**的,而不是靠每一处返回值自觉。
|
|
288
|
+
* 一个刻意的分工:**断言与令牌只在本类里出现**。端点处理函数({@link handlePmsTodoEndpoint})
|
|
289
|
+
* 只把返回值转发出去,因此「响应里没有 token」这件事是**结构上成立**的,而不是靠每一处返回值自觉。
|
|
298
290
|
*/
|
|
299
291
|
export class PmsTodoStore {
|
|
300
292
|
deps;
|
|
301
|
-
/**
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
*
|
|
308
|
+
* 当前状态(spec §4.4)。**永不抛**:失败在快照里表现为 `configured=false` + `reason`。
|
|
330
309
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
310
|
+
* 顺序是刻意的:
|
|
311
|
+
* 1. 手上有一张未过期的 JWT → 直接报 `loggedIn`,**一次网络都不发**(面板每次挂载都读它);
|
|
312
|
+
* 2. 没有 → 问一次网关「到底有没有 `pms` 分配」。`configured` 的判据只能是它:用户填没填过
|
|
313
|
+
* 账号密码这件事已经不存在了(D3),而「有没有分配」只有网关知道。第二次起走内存里的那条
|
|
314
|
+
* 分配,不再打网关。
|
|
334
315
|
*
|
|
335
|
-
* `
|
|
336
|
-
*
|
|
316
|
+
* `reason` 只在「还没就绪」时出现:`no-assignment` / `gateway-signed-out` / `unavailable` 三种,
|
|
317
|
+
* 分别对应「找管理员」/「重新登录 HiWork」/「稍后重试」(spec §3 D8 的 UI 动作各不相同)。
|
|
337
318
|
*/
|
|
338
|
-
async
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
359
|
-
await this.
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
421
|
-
|
|
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
|
|
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
|
-
* 取一个能用的
|
|
396
|
+
* 取一个能用的 PMS JWT(凭据获取的**唯一入口**,spec §3 D10)。
|
|
445
397
|
*
|
|
446
398
|
* 顺序是刻意的:
|
|
447
|
-
* 1.
|
|
448
|
-
* 2.
|
|
449
|
-
*
|
|
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
|
-
* ⚠️
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* 提交一次 `login` 时才用(那时它也确实是用户刚输入的)。
|
|
403
|
+
* ⚠️ **断言绝不缓存**:它只有 120 秒、且 PMS 侧拿 `jti` 做一次性防重放,缓存下来只会是一张废纸。
|
|
404
|
+
* ⚠️ 这里**没有**「拿存下来的账号密码重新登一次」这一支,而且是结构上没有:账号密码通道已删
|
|
405
|
+
* (spec §3 D3),本文件里也搜不到任何密码字段(§7 的验收项)。
|
|
456
406
|
*/
|
|
457
407
|
async authorize() {
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
460
|
-
return
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
* `
|
|
487
|
-
*
|
|
488
|
-
*
|
|
419
|
+
* 判据与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `isCredentialReusable` 同口径:
|
|
420
|
+
* 分母是**这张令牌自己的寿命**(`expiresAt - issuedAt`),不是某个写死的 10 小时——服务端将来
|
|
421
|
+
* 改短有效期,复用口径跟着它走。
|
|
489
422
|
*/
|
|
490
|
-
|
|
423
|
+
reusable(session) {
|
|
491
424
|
const now = this.now();
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
*
|
|
451
|
+
* 取出这条客户端**被分配**的 `pms` 服务(spec §3 D9)。
|
|
452
|
+
*
|
|
453
|
+
* 匹配依据只有 slug(逐字等于 {@link PMS_ASSIGNMENT_SLUG}):清单里还有 visio-mcp 之类的分配,
|
|
454
|
+
* 「取第一条断言分配」在分配多起来之后会随机串台。**不看 `auth.kind`**:那是服务端演进的开放
|
|
455
|
+
* 集合,我们只认自己写死的 slug(D9 的原文就是「唯一匹配依据」)。
|
|
506
456
|
*
|
|
507
|
-
*
|
|
508
|
-
* 因此在同一进程里,读盘只需要一次。跨进程变更(别的进程写了同一个 ref)本就不可观测,
|
|
509
|
-
* 硬要每次重新读只会让「不 remember 的登录」被磁盘上的旧记录当场覆盖。
|
|
457
|
+
* 没有这条分配 → `NOT_CONFIGURED`:UI 提示「找管理员」,**不给任何手工配置入口**(spec §6)。
|
|
510
458
|
*/
|
|
511
|
-
async
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
459
|
+
async ensureAssignment(gateway, cookie) {
|
|
460
|
+
const cached = this.assignment;
|
|
461
|
+
if (cached !== undefined)
|
|
462
|
+
return cached;
|
|
463
|
+
let view;
|
|
515
464
|
try {
|
|
516
|
-
|
|
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.
|
|
527
|
-
|
|
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
|
-
*
|
|
486
|
+
* 签一张断言(120 秒、一次性),并取出它的 `mintUrl`。
|
|
532
487
|
*
|
|
533
|
-
*
|
|
534
|
-
* 「重启后要重新输入」。反过来把登录判为失败会让用户以为密码错了。
|
|
488
|
+
* `mintUrl` 由**门票**给(`auth.mintUrl` 的权威副本在网关侧),本层不猜也不改写它。
|
|
535
489
|
*/
|
|
536
|
-
async
|
|
490
|
+
async assertionTicket(gateway, cookie, assignment) {
|
|
491
|
+
let ticket;
|
|
537
492
|
try {
|
|
538
|
-
await
|
|
539
|
-
this.persisted = true;
|
|
493
|
+
ticket = await gateway.issueMyMcpAssertion(cookie, assignment.assignmentId);
|
|
540
494
|
}
|
|
541
495
|
catch (error) {
|
|
542
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
549
|
-
* **必须**清掉——否则一条旧的 account+refresh token 会在下次启动时被当成「这个人配置过凭据」,
|
|
550
|
-
* 用户明明刚选了「不记住」却在重启后仍是登录态。
|
|
516
|
+
* 日志只记 assignmentId 与事件(**不记断言、不记令牌**,spec §7)。
|
|
551
517
|
*/
|
|
552
|
-
async
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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.
|
|
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 = {
|
|
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
|
-
* 日志纪律:只记**端点名 +
|
|
595
|
-
*
|
|
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':
|