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/pms-client.js CHANGED
@@ -20,6 +20,11 @@
20
20
  * - **错误消息不带响应体**:业务 `code !== 200` 时只把 code 记进 Host 日志,message 一律换成
21
21
  * 稳定文案——PMS 的业务 message 里可能带用户数据 / 表名 / SQL 片段。
22
22
  *
23
+ * HTTP 面只有两类:{@link PmsClient.exchangeAssertion}(网关断言换令牌)与五个 PMS 动作。
24
+ * **账号密码通道已删除**(spec `2026-09-21-pms-zero-input-login-design.md` D3):PMS 自己的
25
+ * `/api/auth/login` / `/api/auth/refresh` 仍在为网页端服务,但桌面端不再调用它们——本文件里**不许**
26
+ * 重新长出一个「填账号密码」的入口(那句输入框就是本次改造要拆掉的东西)。
27
+ *
23
28
  * 本模块不 import cordis 运行时,也不 import 任何 DSH 包:它就是 `fetch` + 形状投影。
24
29
  */
25
30
  import { PKG_VERSION } from "./version.js";
@@ -32,9 +37,6 @@ import { PKG_VERSION } from "./version.js";
32
37
  * 的代码、错误消息、日志),否则「唯一的基址」立刻变成两处能漂移的真相。
33
38
  */
34
39
  export const PMS_BASE_URL = 'https://pms.hivery.cn';
35
- /** 认证端点(相对基址)。 */
36
- const AUTH_LOGIN_PATH = '/api/auth/login';
37
- const AUTH_REFRESH_PATH = '/api/auth/refresh';
38
40
  /** 个人待办端点(相对基址;`setStatus` 的路径按 id 拼,见 {@link PmsClient.setStatus})。 */
39
41
  const TODO_TODAY_PATH = '/api/todo/today';
40
42
  const TODO_STATISTICS_PATH = '/api/todo/statistics';
@@ -52,8 +54,9 @@ const TASK_LIST_PATH = '/api/task/tasks';
52
54
  */
53
55
  export const PMS_REQUEST_TIMEOUT_MS = 10_000;
54
56
  /**
55
- * access token 的**兜底**有效期:10 小时(= PMS `jwt.expiration=36000000`)。
56
- * 只在响应缺 `expiresIn` 时用;正常路径一律按服务端给的 `expiresIn` 算过期时刻。
57
+ * 令牌的**兜底**有效期:10 小时(= PMS `jwt.expiration=36000000`)。
58
+ * 只在兑换响应缺 `expiresAt`(或它不是一个能解析的时间串)时用;正常路径一律按服务端给的那个
59
+ * ISO 串走(见 {@link PmsExchangeResult.expiresAt})。
57
60
  */
58
61
  export const PMS_ACCESS_TOKEN_TTL_MS = 10 * 60 * 60 * 1000;
59
62
  /** 业务 `Result` 信封的成功码。 */
@@ -66,6 +69,10 @@ const PMS_RESULT_SUCCESS_CODE = 200;
66
69
  * 账号密码错、账号被禁用、参数为空都是 **HTTP 200 + `{code:500, message:"…"}`**。
67
70
  * 只看 code 会把「密码错了」显示成「连不上 PMS」——用户会去查网络,而真正该做的是改密码。
68
71
  * 所以判据必须是 **PMS 那句话**(见 {@link isAuthFailureMessage}),401 只是其中一种形态。
72
+ *
73
+ * ⚠️ **本文件已不再调用 PMS 的登录 / 刷新**(2026-09-21 的零输入登录,spec D3)。这段实测仍留着,
74
+ * 因为它服务的是剩下五个动作:JWT 被 PMS 判无效时回来的还是 `Result.failed` 的 code:500 + 同类文案
75
+ * ——那段判据在 `call` 里,用例钉在 REST 路径上(`tests/pms-client.spec.ts`)。
69
76
  */
70
77
  const PMS_RESULT_UNAUTHORIZED_CODE = 401;
71
78
  /**
@@ -75,6 +82,8 @@ const PMS_RESULT_UNAUTHORIZED_CODE = 401;
75
82
  * 归到 `UNAUTHORIZED` 的理由:对用户而言「改账号密码 / 找管理员解封」是同一类动作,
76
83
  * 而**都不许自动重试**(见 `src/pms-todo-bridge.ts` 的登录注释:避免错密码连续触发 PMS 的失败锁定)。
77
84
  * 文案取自 `pms-auth` 的 `AuthService`(登录 / 刷新两条路径的全部失败分支),改 PMS 侧文案要同步这里。
85
+ * 桌面端不再走那两条路,但 PMS 侧照旧为网页端服务,且这套文案同样是「JWT 校验失败」的形态——
86
+ * 判据留在 `call` 里继续为五个 REST 动作服务。
78
87
  */
79
88
  const PMS_AUTH_FAILURE_KEYWORDS = [
80
89
  '用户名或密码错误',
@@ -132,7 +141,13 @@ export class PmsError extends Error {
132
141
  this.name = 'PmsError';
133
142
  }
134
143
  }
135
- /** 未登录 / 凭据失效的文案(与 Bridge 侧 `UNAUTHORIZED` 同口径:引导重新输入,不静默失败)。 */
144
+ /**
145
+ * 未登录 / 凭据失效的文案(与 Bridge 侧 `UNAUTHORIZED` 同口径:引导重新输入,不静默失败)。
146
+ *
147
+ * ⚠️ 换令牌被拒(断言过期 / `aud` 不符 / 工号在 PMS 不存在)也归这个码:那时**该做的是重新登录
148
+ * HiWork 客户端**(断言来自网关会话),不是再输一次 PMS 密码(spec D8)——文案随 Bridge 面的错误
149
+ * 映射一起重写;本层先复用同一个常量,不为一次兑换失败给 `PmsErrorCode` 添新码。
150
+ */
136
151
  export const PMS_UNAUTHORIZED_MESSAGE = '登录已过期,请重新输入 PMS 账号密码';
137
152
  /** 通用失败文案:不暴露任何 PMS 实现细节(baseURL / 路径 / 状态码 / 响应体)。 */
138
153
  export const PMS_GENERIC_FAILURE_MESSAGE = 'PMS 暂时不可用,请稍后重试';
@@ -174,26 +189,67 @@ export class PmsClient {
174
189
  this.log = options.log;
175
190
  }
176
191
  /**
177
- * `POST /api/auth/login`:用账号密码换 JWT(access + refresh)。
192
+ * `POST {mintUrl}`:用一张网关签发的 **120 秒身份断言**换一把 **10 小时 PMS JWT**
193
+ * (spec `2026-09-21-pms-zero-input-login-design.md` §4.1b)。
178
194
  *
179
- * 失败语义(由 {@link PmsClient.call} 统一给出):
180
- * - 账号密码不对 → `UNAUTHORIZED`(HTTP 401 **或** 信封 `code:401`,两者同义);
181
- * - 其余(网络 / 超时 / 5xx / 非业务信封)→ `UPSTREAM_UNAVAILABLE`。
182
- * **本方法不重试**,调用方也不许替它重试(§7:避免用错密码连续触发 PMS 的失败锁定)。
183
- */
184
- async login(username, password) {
185
- const envelope = await this.call(AUTH_LOGIN_PATH, { method: 'POST', body: { username, password } });
186
- return this.tokenSet(envelope.data);
187
- }
188
- /**
189
- * `POST /api/auth/refresh`:用 refresh token 续一次 access token。
195
+ * 与其余方法的两处形状差异都是契约给定的,不是本层偷懒:
196
+ * - **`mintUrl` 是绝对地址**,来自网关的连接器分配(`auth.mintUrl`)——它**不拼** {@link PMS_BASE_URL}:
197
+ * 那个常量是「PMS 部署地址」,而兑换端点由网关的分配清单说了算(本层不猜也不改写,见
198
+ * {@link PmsClient.send});
199
+ * - **不解 `Result` 信封**:成功体就是 `{token, expiresAt, userId, username}`,失败体是
200
+ * `{error:"assertion_rejected"}` 这类——两者都不是业务信封。
201
+ *
202
+ * 失败语义:
203
+ * - HTTP 401 / 403(断言过期、`aud` 不符、签名不过、工号在 PMS 不存在)→ `UNAUTHORIZED`:用户该做的
204
+ * 是**重新登录 HiWork 客户端**(断言来自网关会话),不是再输一次 PMS 密码(spec D8);
205
+ * - 其余(网络 / 超时 / 400 / 503 / 非 JSON / 2xx 却形状不符)→ `UPSTREAM_UNAVAILABLE`。
206
+ *
207
+ * **本方法不重试**:断言是一次性的(PMS 侧拿 `jti` 做 2 分钟防重放),要重试必须由调用方**换一张新
208
+ * 断言**再调——重发同一张只会换来第二个 403(spec §6)。
190
209
  *
191
- * refresh token 失效(7 天到期 / 已被 PMS 撤销)→ `UNAUTHORIZED`,此时**只能**重新输入账号密码;
192
- * 网络问题 → `UPSTREAM_UNAVAILABLE`(那是「稍后重试」,不是「要重新登录」——两者对用户是两件事)。
210
+ * @param mintUrl - 网关分配里的 `auth.mintUrl`(绝对 URL)。
211
+ * @param assertion - 网关签发的短命断言(**不进日志、不落盘**,用完即弃)。
212
+ * @param deviceRef - 设备标识串(可空;只进 PMS 侧审计,不参与鉴权)。
213
+ * @returns 令牌与它的元数据(`token` 交给 Host 内存,见 `src/pms-todo-bridge.ts`)。
193
214
  */
194
- async refresh(refreshToken) {
195
- const envelope = await this.call(AUTH_REFRESH_PATH, { method: 'POST', body: { refreshToken } });
196
- return this.tokenSet(envelope.data);
215
+ async exchangeAssertion(mintUrl, assertion, deviceRef) {
216
+ const response = await this.send(mintUrl, { method: 'POST', body: { assertion, deviceRef } });
217
+ if (response.status === 401 || response.status === 403) {
218
+ // 与 `call` 里 401 的同款纪律:**不读响应体**。`{error:"assertion_rejected"}` 对用户没有信息量,
219
+ // 而它的差异(签名不对 / 过期 / 用户不存在)会把兑换端点变成账号探测器(spec §7)。
220
+ this.log?.(`hiwork-core: PMS 换令牌被拒(HTTP ${response.status})`);
221
+ throw new PmsError('UNAUTHORIZED', PMS_UNAUTHORIZED_MESSAGE, response.status);
222
+ }
223
+ if (response.status < 200 || response.status >= 300) {
224
+ // 400(缺 assertion)/ 503(JWKS 拉不到)等:既不是「凭据问题」也别让用户去做什么。
225
+ this.log?.(`hiwork-core: PMS 换令牌失败(HTTP ${response.status})`);
226
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
227
+ }
228
+ const payload = await readJson(response);
229
+ if (!isRecord(payload)) {
230
+ // 2xx + 非 JSON(反代维护页)→ 上游不可用;**不回显响应内容**。
231
+ this.log?.('hiwork-core: PMS 换令牌返回了非 JSON 响应(可能的中间层)');
232
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
233
+ }
234
+ const token = textOf(payload['token']);
235
+ if (token === undefined) {
236
+ // 200 却不给令牌:形状不符的响应不值得当成成功。
237
+ this.log?.('hiwork-core: PMS 换令牌响应缺 token(契约破坏),按上游不可用处理');
238
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
239
+ }
240
+ // `userId` 不是凭据,但**必须**是安全整数:它是「我的任务」唯一的查询键(`assigneeId`,spec D7)。
241
+ // 缺了它的令牌只能换来一份永远查不到任务的列表——那比一次明确的失败更糟。
242
+ if (!isInteger(payload['userId'])) {
243
+ this.log?.('hiwork-core: PMS 换令牌响应缺 userId / 形态不对(契约破坏),按上游不可用处理');
244
+ throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE, response.status);
245
+ }
246
+ return {
247
+ token,
248
+ // 两个非关键字段按容错处理:令牌已经拿到了,不为一个坏字段让用户重来一次。
249
+ expiresAt: expiresAtOr(payload['expiresAt'], this.now() + PMS_ACCESS_TOKEN_TTL_MS),
250
+ userId: payload['userId'],
251
+ username: textOf(payload['username']) ?? '',
252
+ };
197
253
  }
198
254
  /**
199
255
  * `GET /api/todo/today`:今日待办(⚠️ 口径比名字宽:**今天 + 明天 + 无截止日期**都算,spec §D4)。
@@ -332,15 +388,18 @@ export class PmsClient {
332
388
  };
333
389
  }
334
390
  /**
335
- * 发一次调用并解开 `Result` 信封。**所有错误都在这里收敛**,返回值只有 `data`。
391
+ * 发一次请求:拼地址、塞请求头、按 {@link PmsClientOptions.timeoutMs} 超时。**只有传输**。
336
392
  *
337
- * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
338
- * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
339
- * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
340
- * 误判成「上游故障」而让用户不知道要重新输入密码。
393
+ * 与 {@link PmsClient.call} 的分工:`call` 负责 `Result` 信封与业务码,本方法不管响应体是什么形状
394
+ * ——换令牌那条路({@link PmsClient.exchangeAssertion})既不解信封也不认业务码,但**超时口径与请求头
395
+ * 必须与其余调用逐字相同**(spec §7:统一 10s、令牌只在有身份时发),所以两条路共用这一处,
396
+ * 而不是各抄一份、将来各自漂移。
397
+ *
398
+ * @param url - 相对路径(拼 {@link PmsClient.baseURL})或**绝对 URL**(原样使用)。
399
+ * @param opts - 方法 / 可选 JSON 体 / 可选 Bearer 令牌。
400
+ * @returns 原始 `Response`:状态码由调用方判定,本方法不把任何状态码当成错误。
341
401
  */
342
- async call(path, opts) {
343
- let response;
402
+ async send(url, opts) {
344
403
  try {
345
404
  const headers = {
346
405
  'user-agent': `hiwork-core/${PKG_VERSION}`,
@@ -348,7 +407,7 @@ export class PmsClient {
348
407
  };
349
408
  if (opts.body !== undefined)
350
409
  headers['content-type'] = 'application/json';
351
- // Bearer 头只在有 token 时发(登录/刷新那两条本来就不需要)。
410
+ // Bearer 头只在有令牌时发(换令牌那条路此刻还没有 PMS 身份,spec §4.1b 的「鉴权」一行)。
352
411
  if (opts.accessToken !== undefined)
353
412
  headers['authorization'] = `Bearer ${opts.accessToken}`;
354
413
  const init = {
@@ -358,13 +417,25 @@ export class PmsClient {
358
417
  };
359
418
  if (opts.body !== undefined)
360
419
  init.body = JSON.stringify(opts.body);
361
- response = await fetch(`${this.baseURL}${path}`, init);
420
+ return await fetch(isAbsoluteUrl(url) ? url : `${this.baseURL}${url}`, init);
362
421
  }
363
422
  catch {
364
423
  // DNS / 连接失败 / TLS / 超时(AbortSignal.timeout)——一律「上游不可用」,
365
424
  // 且**不**把底层异常信息带出去(它可能含内网地址)。
366
425
  throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
367
426
  }
427
+ }
428
+ /**
429
+ * 发一次调用并解开 `Result` 信封。**PMS 的业务错误都在这里收敛**,返回值只有 `data`
430
+ * (传输层的失败已在 {@link PmsClient.send} 收敛)。
431
+ *
432
+ * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
433
+ * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
434
+ * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
435
+ * 误判成「上游故障」而让用户不知道要重新输入密码。
436
+ */
437
+ async call(path, opts) {
438
+ const response = await this.send(path, opts);
368
439
  if (response.status === 401) {
369
440
  throw new PmsError('UNAUTHORIZED', PMS_UNAUTHORIZED_MESSAGE, 401);
370
441
  }
@@ -406,38 +477,30 @@ export class PmsClient {
406
477
  }
407
478
  return { code, data: payload['data'] };
408
479
  }
409
- /** 登录 / 刷新的 `data` → {@link PmsTokenSet}。200 却不给 token 视为契约破坏(上游不可用)。 */
410
- tokenSet(data) {
411
- if (!isRecord(data)) {
412
- this.log?.('hiwork-core: PMS 认证响应缺 data(契约破坏),按上游不可用处理');
413
- throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
414
- }
415
- const accessToken = textOf(data['token']);
416
- if (accessToken === undefined) {
417
- this.log?.('hiwork-core: PMS 认证响应缺 token(契约破坏),按上游不可用处理');
418
- throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
419
- }
420
- const refreshToken = textOf(data['refreshToken']);
421
- const expiresIn = data['expiresIn'];
422
- // `expiresIn` 是**秒**(PMS 侧 `jwt.expiration/1000` 量级),非正数 / 缺失按 10 小时兜底。
423
- const ttlMs = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0
424
- ? expiresIn * 1000
425
- : PMS_ACCESS_TOKEN_TTL_MS;
426
- // `userId` 只在**是安全整数**时收下(登录响应里就有,spec §3 D7:我的任务要用它做 assigneeId);
427
- // 形态不对就当没给——「我的任务」会据此引导用户重新登录一次,比拿一个脏 id 去查询好。
428
- const userId = isInteger(data['userId']) ? data['userId'] : undefined;
429
- return {
430
- accessToken,
431
- ...(refreshToken === undefined ? {} : { refreshToken }),
432
- ...(userId === undefined ? {} : { userId }),
433
- expiresAt: this.now() + ttlMs,
434
- };
435
- }
436
480
  }
437
481
  /** 宽松的记录判定(数组与 null 都不算)。 */
438
482
  function isRecord(value) {
439
483
  return typeof value === 'object' && value !== null && !Array.isArray(value);
440
484
  }
485
+ /** 绝对地址判定(只认 http / https 两个协议;大小写不敏感)。 */
486
+ function isAbsoluteUrl(url) {
487
+ return /^https?:\/\//i.test(url);
488
+ }
489
+ /**
490
+ * 兑换响应里的 `expiresAt`(spec §4.1b 的 ISO 串)**原样透传**;缺失 / 不是能解析的时间串时退回兜底时刻。
491
+ *
492
+ * 与 `hiwork-capabilities` 的 `parseExpiresAt` 同口径:一个坏字段不该把**已经拿到**的令牌判成失败
493
+ * ——兜底 10 小时(= PMS 的 `jwt.expiration`)比让用户重来一次有用。判据只认「`Date.parse` 认不认」,
494
+ * 不自己写 ISO 正则:多一种时间串写法对我们是免费的,而卡死格式只会让一次成功兑换白跑。
495
+ * @param value - 响应里的原值。
496
+ * @param fallbackMs - 兜底时刻(epoch ms)。
497
+ * @returns 原样的时间串,或兜底时刻的 ISO 串。
498
+ */
499
+ function expiresAtOr(value, fallbackMs) {
500
+ if (typeof value === 'string' && value !== '' && Number.isFinite(Date.parse(value)))
501
+ return value;
502
+ return new Date(fallbackMs).toISOString();
503
+ }
441
504
  /** 非空字符串或 undefined(一个字段坏了不该让整条待办失败)。 */
442
505
  function textOf(value) {
443
506
  return typeof value === 'string' && value.trim() !== '' ? value : undefined;