hiwork-core 0.0.22 → 0.0.29

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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * 知识库配置下发的只读 host 服务(`hiworkKnowledge`)。
3
+ *
4
+ * 背景:HiWork 给员工接了一套知识库(WeKnora)。规范是**员工零操作**——知识库的
5
+ * 服务地址、凭据、问答 Agent ID、chat 模型 ID **全部由网关下发**,插件的设置页
6
+ * 不得让员工填任何技术字段。本模块是这条链中间的那一环:
7
+ *
8
+ * ```
9
+ * hiwork-admin GET /api/me/knowledge-config ← 网关侧(纯读:无 DB、无上游调用)
10
+ * ↑ 本文件(hiwork-core,用登录会话调它)
11
+ * hiwork-knowledge ctx.get('hiworkKnowledge').config() ← 插件侧
12
+ * ```
13
+ *
14
+ * 形状来源(逐字对照):`src/memory-config.ts`(同族先例,同样是「网关配置下发的只读
15
+ * 面」)。与它的差异**只有三处**:
16
+ * ① 字段集多 `agentId` / `chatModelId`(问答链路需要);
17
+ * ② 必填判定只看 `base_url` + `api_key`:**缺 agentId / chatModelId 不阻止下发**
18
+ * (返回 `''`,检索与浏览仍可用),只有两个必填项缺失才判整份不可用;
19
+ * ③ 状态码映射里**保留** `504 → unreachable`,但它是**防御性分类**:本端点不打上游,
20
+ * 网关不会回 504(网关侧 spec §3 D1/D5),保留这一档是为了将来换实现时不会把
21
+ * 「上游不可达」误读成 `error`。
22
+ *
23
+ * 不变量(与 memory 同口径,不得放宽):
24
+ * - **只读、失败不抛异常**:一律返回 `{ ok: false, reason, message }`,reason 四取值
25
+ * `not-configured` / `unauthenticated` / `unreachable` / `error`。失败是**数据**而不是
26
+ * 异常:插件侧按 `ok` 判断,绝不会因为宿主的一次网络抖动把异常漏进会话。
27
+ * - **字段名分层只在本文件做**:网关那层是 **snake_case**(`base_url` / `api_key` /
28
+ * `agent_id` / `chat_model_id` / `tenant_id`,跨仓冻结契约),本文件把它转成 camelCase
29
+ * 的 {@link HiworkKnowledgeConfigView} 再对外暴露——插件侧**永远不感知上游命名**,
30
+ * 本文件是全仓**唯一**做这层转换的地方。
31
+ * - **凭据只在内存里活**:`apiKey` 不写 settings、不写任何 storage、不写任何缓存文件
32
+ * (与 `src/memory-config.ts` / `src/office-credentials.ts` 同口径),也**不进任何日志**
33
+ * (含调试日志与错误日志)——本模块只记事件与异常**类型名**,绝不记字段值。
34
+ * - **5 分钟内存缓存**:命中即不出网;`force: true` 绕过缓存(设置页「重新获取配置」与
35
+ * 401 自愈走这条)。缓存与**会话 cookie 绑定**:换了登录账号(登出再登入)时旧缓存
36
+ * 直接失效,绝不把上一个员工的配置发给下一个员工(粘错 = 串号)。
37
+ * - **失败不进缓存**:拉不到时下一次调用重新出网——否则一次网关抖动会把员工锁在
38
+ * 「知识库不可用」里整整一个 TTL。
39
+ * - **message 是给非技术员工看的中文**:不含凭据、不含 URL 原文、不含 HTTP 状态码。
40
+ *
41
+ * 服务名逐字 `hiworkKnowledge`(插件侧 spec §3 D1 对既有 spec 的一处口径修正:旧文档写的是
42
+ * 「Knowledge + Credentials」的拼接名,本模块下发的**不只是凭据**(还有 baseUrl、Agent ID、
43
+ * chat 模型 ID),故不叫 Credentials;源码里不得出现那个旧名,V3 用 grep 守着)。
44
+ *
45
+ * 本模块不 import cordis 运行时:provide 走结构化的 {@link KnowledgeConfigContext} 端口,
46
+ * 调用方(index.ts)传真实 ctx;单测直接伪造。
47
+ */
48
+ import { PKG_VERSION } from "./version.js";
49
+ /** cordis 服务名(hiwork-knowledge 的 Host 半边按此名 `ctx.get` 探测)。 */
50
+ export const HIWORK_KNOWLEDGE_SERVICE = 'hiworkKnowledge';
51
+ /** 网关侧端点(跨仓冻结;与 `/api/me/memory-config` 同一条会话鉴权)。 */
52
+ export const ME_KNOWLEDGE_CONFIG_PATH = '/api/me/knowledge-config';
53
+ /** 成功结果的缓存 TTL:5 分钟。 */
54
+ export const KNOWLEDGE_CONFIG_TTL_MS = 5 * 60 * 1000;
55
+ /** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
56
+ export const KNOWLEDGE_CONFIG_TIMEOUT_MS = 10_000;
57
+ /**
58
+ * 失败文案(给非技术员工看的中文;不含凭据、URL 原文、HTTP 状态码)。
59
+ * 四句与插件侧 spec §4.1 / §5 逐字一致,设置页直接渲染,不得改造。
60
+ */
61
+ const MESSAGE_NOT_CONFIGURED = '管理员还没开启知识库,请联系管理员开通';
62
+ const MESSAGE_UNAUTHENTICATED = '登录状态已过期,请重新登录';
63
+ const MESSAGE_UNREACHABLE = '知识库服务暂时不可用,请稍后重试';
64
+ const MESSAGE_GENERIC = '知识库配置暂时无法获取,请稍后重试';
65
+ /**
66
+ * 按 reason 取文案。
67
+ * 注:`gateway()` 返回 undefined(还没登录完 / baseURL 未配置)与网络不通归同一档
68
+ * `unreachable`,共用同一句——本 spec 只冻结了四句文案,不另造第五句。
69
+ */
70
+ function messageFor(reason) {
71
+ switch (reason) {
72
+ case 'not-configured': return MESSAGE_NOT_CONFIGURED;
73
+ case 'unauthenticated': return MESSAGE_UNAUTHENTICATED;
74
+ case 'unreachable': return MESSAGE_UNREACHABLE;
75
+ case 'error': return MESSAGE_GENERIC;
76
+ }
77
+ }
78
+ /** 失败视图(**只带 reason 与 message**,绝不带半个字段)。 */
79
+ function failure(reason, message) {
80
+ return { ok: false, reason, message: message ?? messageFor(reason) };
81
+ }
82
+ /** 裁掉两侧空白;非字符串或空白串一律视同缺省。 */
83
+ function textOrUndefined(value) {
84
+ if (typeof value !== 'string')
85
+ return undefined;
86
+ const text = value.trim();
87
+ return text === '' ? undefined : text;
88
+ }
89
+ /**
90
+ * snake_case → camelCase 投影(**全仓唯一做这层转换的地方**)。
91
+ *
92
+ * 两个必需字段(`base_url` / `api_key`)任一缺失即返回 undefined:网关 200 却缺字段 =
93
+ * 契约破坏,宁可报「暂时无法获取」,也绝不把半份配置交给终端(半份配置的下场是插件拿
94
+ * 空地址去打请求,错误信息离真正的原因十万八千里)。
95
+ *
96
+ * 三个**非必需**字段(`agent_id` / `chat_model_id` / `tenant_id`)缺失 / 空白 / 形状非法
97
+ * 一律落成 `''`,**整份仍算下发成功**——检索与浏览不依赖它们,只有问答需要;用「问答
98
+ * 用不了」冒充「知识库不可用」会把员工引到错误的方向(spec §4.1 投影规则逐字)。
99
+ *
100
+ * 同时宽容接受 camelCase 别名(`baseUrl` / `apiKey` / `agentId` / `chatModelId` /
101
+ * `tenantId`):两侧落地顺序不同也不炸。**写出永远是 camelCase 视图**——插件侧不感知
102
+ * 任何一种上游命名。
103
+ */
104
+ function projectKnowledgeConfig(body) {
105
+ const baseUrl = textOrUndefined(body['base_url'] ?? body['baseUrl']);
106
+ const apiKey = textOrUndefined(body['api_key'] ?? body['apiKey']);
107
+ if (baseUrl === undefined || apiKey === undefined)
108
+ return undefined;
109
+ return {
110
+ ok: true,
111
+ baseUrl,
112
+ apiKey,
113
+ agentId: textOrUndefined(body['agent_id'] ?? body['agentId']) ?? '',
114
+ chatModelId: textOrUndefined(body['chat_model_id'] ?? body['chatModelId']) ?? '',
115
+ tenantId: textOrUndefined(body['tenant_id'] ?? body['tenantId']) ?? '',
116
+ };
117
+ }
118
+ /** 异常类型名(日志只记类型,不记消息,避免把凭据/URL 带进日志)。 */
119
+ function errorName(error) {
120
+ return error instanceof Error ? error.name : typeof error;
121
+ }
122
+ /**
123
+ * 把「会抛错的网关实现」的稳定 code 映射成 reason。
124
+ *
125
+ * 本模块自带的出网端口({@link createKnowledgeConfigGateway})**从不抛错**,这一支是为了
126
+ * 容忍接线侧传进来的其它实现(例如将来把本端点加进 `AiRouterClient` 后直接复用
127
+ * `gatewayFor(baseURL)`):按结构化 `code` 字段判别,不 import 对方的类,也不读 message
128
+ * (message 里可能带 URL 原文)。
129
+ */
130
+ function reasonOfErrorCode(error) {
131
+ const code = error?.code;
132
+ if (typeof code !== 'string')
133
+ return undefined;
134
+ switch (code) {
135
+ case 'SESSION_EXPIRED': return 'unauthenticated';
136
+ case 'KNOWLEDGE_NOT_CONFIGURED': return 'not-configured';
137
+ case 'KNOWLEDGE_UNREACHABLE':
138
+ case 'GATEWAY_UNREACHABLE': return 'unreachable';
139
+ default: return undefined;
140
+ }
141
+ }
142
+ /** 非 200 状态码 → reason(逐字对应契约:401 / 503 / 504 各有确定语义)。 */
143
+ function reasonOfStatus(status) {
144
+ if (status === 401)
145
+ return 'unauthenticated';
146
+ // 网关 503 `KNOWLEDGE_NOT_CONFIGURED`:管理员还没开启知识库(部署状态,不是故障)。
147
+ if (status === 503)
148
+ return 'not-configured';
149
+ // 504:**防御性分类**——本端点不打上游,网关不会回 504;保留它是为了将来换实现时
150
+ // 不会把「上游不可达」读成 error(语义上 504 就是「够不着」)。
151
+ if (status === 504)
152
+ return 'unreachable';
153
+ return 'error';
154
+ }
155
+ /** 视图投影 + 5 分钟内存缓存(无状态依赖,只有一条内存缓存)。 */
156
+ class KnowledgeConfigProvider {
157
+ deps;
158
+ cached;
159
+ constructor(deps) {
160
+ this.deps = deps;
161
+ }
162
+ async config(opts) {
163
+ const force = opts?.force === true;
164
+ let sessionCookie;
165
+ try {
166
+ sessionCookie = await this.deps.resolveSession();
167
+ }
168
+ catch (error) {
169
+ // 凭据服务读取失败是宿主故障,不是「没登录」:如实报 error,不诱导用户反复重登。
170
+ this.deps.log?.(`hiwork-core: 知识库配置获取失败(${errorName(error)})`);
171
+ return failure('error');
172
+ }
173
+ if (sessionCookie === undefined || sessionCookie === '')
174
+ return failure('unauthenticated');
175
+ if (!force) {
176
+ const hit = this.cached;
177
+ if (hit !== undefined && hit.sessionCookie === sessionCookie && this.now() - hit.at < KNOWLEDGE_CONFIG_TTL_MS) {
178
+ return hit.view;
179
+ }
180
+ }
181
+ const gateway = this.deps.gateway();
182
+ // 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
183
+ if (gateway === undefined)
184
+ return failure('unreachable');
185
+ let outcome;
186
+ try {
187
+ outcome = await gateway.getMyKnowledgeConfig(sessionCookie);
188
+ }
189
+ catch (error) {
190
+ const mapped = reasonOfErrorCode(error);
191
+ if (mapped !== undefined)
192
+ return failure(mapped);
193
+ this.deps.log?.(`hiwork-core: 知识库配置获取失败(${errorName(error)})`);
194
+ return failure('error');
195
+ }
196
+ if (!outcome.ok) {
197
+ if (outcome.kind === 'network')
198
+ return failure('unreachable');
199
+ if (outcome.kind === 'http')
200
+ return failure(reasonOfStatus(outcome.status));
201
+ return failure('error');
202
+ }
203
+ const view = projectKnowledgeConfig(outcome.body);
204
+ if (view === undefined) {
205
+ // 只记事件与字段名,不记任何值(响应体里带凭据)。
206
+ this.deps.log?.('hiwork-core: 网关返回的 knowledge-config 不完整(缺 base_url / api_key 之一)');
207
+ return failure('error');
208
+ }
209
+ // 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
210
+ this.cached = { sessionCookie, view, at: this.now() };
211
+ return view;
212
+ }
213
+ now() {
214
+ return (this.deps.now ?? Date.now)();
215
+ }
216
+ }
217
+ /**
218
+ * 提供 host 侧服务 `hiworkKnowledge`;返回 disposer(交给 `ctx.effect` 管理生命周期,
219
+ * 插件卸载时服务随 fiber 一并注销)。
220
+ */
221
+ export function provideKnowledgeConfig(ctx, deps) {
222
+ const service = new KnowledgeConfigProvider(deps);
223
+ const dispose = ctx.provide(HIWORK_KNOWLEDGE_SERVICE, service);
224
+ return typeof dispose === 'function' ? dispose : () => { };
225
+ }
226
+ /**
227
+ * 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
228
+ * 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
229
+ *
230
+ * **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
231
+ * 没有任何密钥/令牌在这一层生成或保存。
232
+ *
233
+ * **永不抛异常、绝不回显响应原文**:失败收敛成 {@link KnowledgeConfigOutcome},日志与
234
+ * 异常里都不会出现 URL、状态码或响应体——响应体里带的是知识库凭据。
235
+ *
236
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
237
+ */
238
+ export function createKnowledgeConfigGateway(baseURL, opts = {}) {
239
+ const root = baseURL.replace(/\/+$/, '');
240
+ const timeoutMs = opts.timeoutMs ?? KNOWLEDGE_CONFIG_TIMEOUT_MS;
241
+ const doFetch = opts.fetchImpl ?? fetch;
242
+ return {
243
+ async getMyKnowledgeConfig(sessionCookie) {
244
+ try {
245
+ const res = await doFetch(`${root}${ME_KNOWLEDGE_CONFIG_PATH}`, {
246
+ method: 'GET',
247
+ headers: {
248
+ 'user-agent': `hiwork-core/${PKG_VERSION}`,
249
+ cookie: sessionCookie,
250
+ },
251
+ signal: AbortSignal.timeout(timeoutMs),
252
+ });
253
+ if (!res.ok)
254
+ return { ok: false, kind: 'http', status: res.status };
255
+ let body;
256
+ try {
257
+ body = await res.json();
258
+ }
259
+ catch {
260
+ // 非 JSON 响应:只报「不可解析」,不把响应片段带进任何返回值或日志。
261
+ return { ok: false, kind: 'invalid' };
262
+ }
263
+ if (body === null || typeof body !== 'object')
264
+ return { ok: false, kind: 'invalid' };
265
+ return { ok: true, body };
266
+ }
267
+ catch {
268
+ // 超时(AbortSignal.timeout → TimeoutError)/ 连接失败 / DNS:一律按不可达。
269
+ return { ok: false, kind: 'network' };
270
+ }
271
+ },
272
+ };
273
+ }
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`。
190
206
  *
191
- * refresh token 失效(7 天到期 / 已被 PMS 撤销)→ `UNAUTHORIZED`,此时**只能**重新输入账号密码;
192
- * 网络问题 → `UPSTREAM_UNAVAILABLE`(那是「稍后重试」,不是「要重新登录」——两者对用户是两件事)。
207
+ * **本方法不重试**:断言是一次性的(PMS 侧拿 `jti` 做 2 分钟防重放),要重试必须由调用方**换一张新
208
+ * 断言**再调——重发同一张只会换来第二个 403(spec §6)。
209
+ *
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,22 @@ 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
+ * **重定向自己跟**(`redirect: 'manual'` + {@link redirectTargetOf}):`fetch` 的默认 `follow` 会把
399
+ * 重定向后的请求按 fetch 语义**把 POST 改成 GET**,于是请求落在一个只认 POST 的映射上、拿到一个
400
+ * 「200 + 业务错误信封」,看上去像上游坏了。判据与修法见 {@link PMS_MAX_REDIRECTS}。
401
+ *
402
+ * @param url - 相对路径(拼 {@link PmsClient.baseURL})或**绝对 URL**(原样使用)。
403
+ * @param opts - 方法 / 可选 JSON 体 / 可选 Bearer 令牌。
404
+ * @returns 原始 `Response`:状态码由调用方判定,本方法不把任何状态码当成错误。
341
405
  */
342
- async call(path, opts) {
343
- let response;
406
+ async send(url, opts) {
344
407
  try {
345
408
  const headers = {
346
409
  'user-agent': `hiwork-core/${PKG_VERSION}`,
@@ -348,23 +411,46 @@ export class PmsClient {
348
411
  };
349
412
  if (opts.body !== undefined)
350
413
  headers['content-type'] = 'application/json';
351
- // Bearer 头只在有 token 时发(登录/刷新那两条本来就不需要)。
414
+ // Bearer 头只在有令牌时发(换令牌那条路此刻还没有 PMS 身份,spec §4.1b 的「鉴权」一行)。
352
415
  if (opts.accessToken !== undefined)
353
416
  headers['authorization'] = `Bearer ${opts.accessToken}`;
354
417
  const init = {
355
418
  method: opts.method,
356
419
  headers,
357
- signal: AbortSignal.timeout(this.timeoutMs),
420
+ // 手动跟随:默认的 `follow` 会把重定向后的 POST 降级成 GET(见本方法的抬头)。
421
+ redirect: 'manual',
358
422
  };
359
423
  if (opts.body !== undefined)
360
424
  init.body = JSON.stringify(opts.body);
361
- response = await fetch(`${this.baseURL}${path}`, init);
425
+ // 截止时刻是**整条链**的预算(10s 不是「每跳各给 10s」)。
426
+ const deadline = Date.now() + this.timeoutMs;
427
+ let target = isAbsoluteUrl(url) ? url : `${this.baseURL}${url}`;
428
+ let response = await fetchWithinDeadline(target, init, deadline);
429
+ for (let hop = 0;; hop += 1) {
430
+ const next = redirectTargetOf(target, response, hop);
431
+ if (next === undefined)
432
+ return response;
433
+ target = next;
434
+ response = await fetchWithinDeadline(target, init, deadline);
435
+ }
362
436
  }
363
437
  catch {
364
- // DNS / 连接失败 / TLS / 超时(AbortSignal.timeout)——一律「上游不可用」,
438
+ // DNS / 连接失败 / TLS / 超时(AbortSignal.timeout)/ 超过跳数 —— 一律「上游不可用」,
365
439
  // 且**不**把底层异常信息带出去(它可能含内网地址)。
366
440
  throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
367
441
  }
442
+ }
443
+ /**
444
+ * 发一次调用并解开 `Result` 信封。**PMS 的业务错误都在这里收敛**,返回值只有 `data`
445
+ * (传输层的失败已在 {@link PmsClient.send} 收敛)。
446
+ *
447
+ * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
448
+ * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
449
+ * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
450
+ * 误判成「上游故障」而让用户不知道要重新输入密码。
451
+ */
452
+ async call(path, opts) {
453
+ const response = await this.send(path, opts);
368
454
  if (response.status === 401) {
369
455
  throw new PmsError('UNAUTHORIZED', PMS_UNAUTHORIZED_MESSAGE, 401);
370
456
  }
@@ -406,38 +492,107 @@ export class PmsClient {
406
492
  }
407
493
  return { code, data: payload['data'] };
408
494
  }
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
495
  }
437
496
  /** 宽松的记录判定(数组与 null 都不算)。 */
438
497
  function isRecord(value) {
439
498
  return typeof value === 'object' && value !== null && !Array.isArray(value);
440
499
  }
500
+ /** 绝对地址判定(只认 http / https 两个协议;大小写不敏感)。 */
501
+ function isAbsoluteUrl(url) {
502
+ return /^https?:\/\//i.test(url);
503
+ }
504
+ /**
505
+ * 最多跟随的**重定向跳数**(超过即放弃,把最后那个 3xx 原样交给调用方 → 上游不可用)。
506
+ *
507
+ * 为什么要有这个上限:重定向跟随是本次加固里唯一一处「会替调用方多发一次请求」的地方,
508
+ * 上限定小了(2 跳)足以覆盖真实形态(`http://` 登记 → nginx 302 → `https://` 一跳;
509
+ * 见过的最多是「域名 → 规范域名 → 真实地址」两跳),定大了只会在配错时把断言连打出去。
510
+ */
511
+ export const PMS_MAX_REDIRECTS = 2;
512
+ /**
513
+ * 会「原样重发」的重定向状态码:301 / 302 / 307 / 308。
514
+ *
515
+ * **303 刻意不在其中**:按 RFC 与 fetch 语义,303 的语义就是「用 GET 去取那个地址」——把它也变成
516
+ * 「保持 POST 重发」等于替服务端改主意。本层宁可把 303 原样交出去(调用方按上游不可用收敛),
517
+ * 也不猜服务端想要哪一种。
518
+ */
519
+ const METHOD_PRESERVING_REDIRECTS = new Set([301, 302, 307, 308]);
520
+ /**
521
+ * 决定这一跳要不要跟、跟去哪。`undefined` = **到此为止**(把当前这份 `Response` 交给调用方)。
522
+ *
523
+ * 背景(生产实测的故障):平台把服务登记成 `http://pms.hivery.cn/mcp/credential-exchange`,
524
+ * nginx 回 302 到 https,而 `fetch` 默认跟随时会按 fetch 语义**把 POST 改成 GET**——于是请求落在
525
+ * 一个只认 `@PostMapping` 的映射上,应用回「200 + `{"code":500}`」信封,桌面端只看到「待办暂时
526
+ * 拉不到」。所以这里手动跟随,并**保持方法与请求体**。
527
+ *
528
+ * 三条闸(都是「宁可不跟」的方向,因为跟着走的那个请求体里装的是**凭据**:mint 的 body 是 120 秒断言):
529
+ * - **状态码**:只有 {@link METHOD_PRESERVING_REDIRECTS} 里的码才跟;
530
+ * - **主机名必须相同**(端口与 scheme 可变,见下):跨主机名跟随等于把断言发给第三方;
531
+ * - **禁止 https → http 降级**(http → https 升级允许):凭据不该因为一次重定向而落到明文上。
532
+ *
533
+ * 端口为什么允许变:真实拓扑里「对外 443 / 内网 8080」是常态,主机名才是「我是不是在跟同一个部署
534
+ * 说话」的边界;scheme 从 http 升到 https(本次的真实形态)跨端口更常见。
535
+ *
536
+ * @param current - 本次请求实际打的地址(绝对 URL)。
537
+ * @param response - 它的响应(只读 `status` 与 `location`)。
538
+ * @param hop - 已经跟随过几跳(从 0 起)。
539
+ * @returns 下一跳的绝对地址;不跟时 `undefined`。
540
+ */
541
+ export function redirectTargetOf(current, response, hop) {
542
+ if (!METHOD_PRESERVING_REDIRECTS.has(response.status))
543
+ return undefined;
544
+ if (hop >= PMS_MAX_REDIRECTS)
545
+ return undefined;
546
+ const location = response.headers.get('location');
547
+ if (location === null || location.trim() === '')
548
+ return undefined;
549
+ let from;
550
+ let next;
551
+ try {
552
+ from = new URL(current);
553
+ // 相对 `Location` 按当前地址解析(HTTP 允许)。
554
+ next = new URL(location.trim(), current);
555
+ }
556
+ catch {
557
+ return undefined;
558
+ }
559
+ if (next.protocol !== 'https:' && next.protocol !== from.protocol)
560
+ return undefined;
561
+ if (next.hostname !== from.hostname)
562
+ return undefined;
563
+ return next.toString();
564
+ }
565
+ /**
566
+ * 一次 `fetch`,但超时按**整条链共用的截止时刻**算(含重定向后的那些跳)。
567
+ *
568
+ * 为什么不是每跳各给 {@link PmsClientOptions.timeoutMs}:spec §7 冻结的是「统一 10s 超时」,
569
+ * 而一次取数最多可能走三跳;每跳各给 10s 会把面板的转圈时间变成 30s。
570
+ *
571
+ * @param target - 绝对地址。
572
+ * @param init - 方法 / 头 / 体(**同一个 init 对象复用于每一跳**,方法与体因此天然一致)。
573
+ * @param deadline - 整条链的截止时刻(epoch ms)。
574
+ */
575
+ async function fetchWithinDeadline(target, init, deadline) {
576
+ const remaining = deadline - Date.now();
577
+ if (remaining <= 0)
578
+ throw new Error('pms request deadline exceeded');
579
+ return await fetch(target, { ...init, signal: AbortSignal.timeout(remaining) });
580
+ }
581
+ /**
582
+ * 兑换响应里的 `expiresAt`(spec §4.1b 的 ISO 串)**原样透传**;缺失 / 不是能解析的时间串时退回兜底时刻。
583
+ *
584
+ * 与 `hiwork-capabilities` 的 `parseExpiresAt` 同口径:一个坏字段不该把**已经拿到**的令牌判成失败
585
+ * ——兜底 10 小时(= PMS 的 `jwt.expiration`)比让用户重来一次有用。判据只认「`Date.parse` 认不认」,
586
+ * 不自己写 ISO 正则:多一种时间串写法对我们是免费的,而卡死格式只会让一次成功兑换白跑。
587
+ * @param value - 响应里的原值。
588
+ * @param fallbackMs - 兜底时刻(epoch ms)。
589
+ * @returns 原样的时间串,或兜底时刻的 ISO 串。
590
+ */
591
+ function expiresAtOr(value, fallbackMs) {
592
+ if (typeof value === 'string' && value !== '' && Number.isFinite(Date.parse(value)))
593
+ return value;
594
+ return new Date(fallbackMs).toISOString();
595
+ }
441
596
  /** 非空字符串或 undefined(一个字段坏了不该让整条待办失败)。 */
442
597
  function textOf(value) {
443
598
  return typeof value === 'string' && value.trim() !== '' ? value : undefined;