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,76 +1,99 @@
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 { type PmsMyTasksOptions, type PmsTaskList as PmsTaskListView, type PmsTaskStatusResult as PmsTaskStatusResultView, type PmsTodoItem, type PmsTodoStatistics as PmsTodoStatisticsView, type PmsTodoStatusResult as PmsTodoStatusResultView, type PmsTodoStatusValue, type PmsTokenSet } from './pms-client.ts';
32
+ import { type GatewayMcpAssertionTicket, type GatewayMcpAssignmentsView } from './ai-router-client.ts';
33
+ import { type PmsExchangeResult, type PmsMyTasksOptions, type PmsTaskList as PmsTaskListView, type PmsTaskStatusResult as PmsTaskStatusResultView, type PmsTodoItem, type PmsTodoStatistics as PmsTodoStatisticsView, type PmsTodoStatusResult as PmsTodoStatusResultView, type PmsTodoStatusValue } from './pms-client.ts';
34
34
  /** RPC 频道;必须与 client 半边 `src/client/pms-todo.ts` 的 `PMS_TODO_CHANNEL` 一致(由测试锁定)。 */
35
35
  export declare 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
- export declare const PMS_TODO_RPC_ENDPOINTS: readonly ["status", "login", "logout", "today", "statistics", "setStatus", "myTasks", "completeTask"];
47
+ export declare const PMS_TODO_RPC_ENDPOINTS: readonly ["status", "today", "statistics", "setStatus", "myTasks", "completeTask"];
49
48
  export type PmsTodoRpcEndpoint = (typeof PMS_TODO_RPC_ENDPOINTS)[number];
50
49
  /**
51
- * 凭据 ref(spec §4.4,credentials 服务,加密落盘)。
52
- *
53
- * 值是一段 JSON:`{username, password?, refreshToken?, expiresAt?}`。**只有 `remember` 为真时
54
- * 才写这个 ref**;不记住时凭据只活在进程内存里。别把它挪进 settings —— settings 是明文 YAML,
55
- * 而且会出现在设置页快照里(§7 第一条)。
50
+ * 分配匹配的**唯一**依据(spec §3 D9):`GET /api/me/mcp-assignments` 里 `slug` 逐字等于它的那条。
51
+ * 它就是断言的 `aud`(网关侧注释:`aud` = 目标服务的 slug),也是网关连接器的 id。
52
+ */
53
+ export declare const PMS_ASSIGNMENT_SLUG = "pms";
54
+ /**
55
+ * v1 遗留的凭据 ref(spec §4.5 / D4)。**本文件不再读写它**——值只被
56
+ * {@link clearLegacyCredentials} 用来做首启的一次性清理;随后它就从配置面消失了。
56
57
  */
57
58
  export declare const PMS_CREDENTIALS_REF = "HIWORK_PMS_CREDENTIALS";
58
59
  /**
59
- * 凭据记录里 `expiresAt` 缺失时的兜底有效期:10 小时(与 `src/pms-client.ts` 同值)。
60
- * 正常路径一律写服务端给的 `expiresIn` 算出来的绝对时刻。
60
+ * 令牌的**兜底**有效期:10 小时(= PMS `jwt.expiration=36000000`)。
61
+ * 正常路径一律用 mint 响应给的 `expiresAt`;只有它解析不了时才用它兜底(见 `sessionOf`)。
61
62
  */
62
63
  export declare const PMS_TODO_ACCESS_TOKEN_TTL_MS: number;
63
64
  /**
64
- * Host 侧六个稳定错误码(spec §4.1 的五个 + §3 D7 追加的 `FORBIDDEN`;**只允许**这六个)。
65
+ * 令牌复用口径(spec §4.5):剩余寿命不低于原寿命这个比例时复用,否则重换。
66
+ *
67
+ * 值与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `REUSE_MIN_REMAINING_RATIO` 相同
68
+ * ——那是受管连接器的同一条通道,两处口径必须一致(跨仓不能 import,所以值在这里复写一份并说明)。
69
+ * 一半是权衡:太低(5%)意味着下一次取数很可能正好撞在过期瞬间;太高(95%)等于每次取数都白换一次。
70
+ */
71
+ export declare const PMS_TODO_REUSE_MIN_REMAINING_RATIO = 0.5;
72
+ /**
73
+ * Host 侧六个稳定错误码(spec §4.3 的六个,一个不增不减;语义与 UI 动作按 §3 D8 重写)。
65
74
  *
66
75
  * `FORBIDDEN` 与 `UNAUTHORIZED` 分开:权限不足(`task:list:edit`)**重新登录也解决不了**,
67
- * 引导用户改密码只会绕圈;UI 要按 §3 D7 明说「在 HiWork 里没有完成任务的权限,请到 PMS 操作」。
76
+ * 引导用户重新登录 HiWork 只会绕圈;UI 要按 §3 D7 明说「在 HiWork 里没有完成任务的权限,请到 PMS 操作」。
68
77
  */
69
78
  export type PmsTodoErrorCode = 'NOT_CONFIGURED' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'UPSTREAM_UNAVAILABLE' | 'BAD_REQUEST' | 'INTERNAL';
70
- /** 从未配置凭据的文案(UI 据此渲染引导卡片,不做静默失败)。 */
71
- export declare const PMS_TODO_NOT_CONFIGURED_MESSAGE = "\u5C1A\u672A\u914D\u7F6E PMS \u8D26\u53F7\uFF0C\u8BF7\u5148\u8F93\u5165\u8D26\u53F7\u5BC6\u7801";
72
- /** 凭据失效的文案(UI 据此要求重新输入,且保留上次数据可见)。 */
73
- export declare const PMS_TODO_UNAUTHORIZED_MESSAGE = "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8F93\u5165 PMS \u8D26\u53F7\u5BC6\u7801";
79
+ /**
80
+ * `status` 里的「为什么还没就绪」(spec §4.4 新增的可选字段,面板据此选文案)。
81
+ *
82
+ * 三种取值对应三个不同的人 / 动作:`no-assignment` → 找管理员(客户端没被接进 PMS);
83
+ * `gateway-signed-out` → 用户自己在 HiWork 客户端重新登录;`unavailable` → 稍后重试(保留上次数据)。
84
+ */
85
+ export type PmsTodoStatusReason = 'no-assignment' | 'gateway-signed-out' | 'unavailable';
86
+ /**
87
+ * 网关里没有 `pms` 分配时的文案(spec §6 第一行:纯提示「联系管理员」,**不给任何手工入口**)。
88
+ */
89
+ export declare const PMS_TODO_NOT_CONFIGURED_MESSAGE = "\u7BA1\u7406\u5458\u5C1A\u672A\u628A PMS \u63A5\u5165\u8FD9\u53F0\u5BA2\u6237\u7AEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458";
90
+ /**
91
+ * 网关会话失效 / mint 被拒(403)的文案:动作在 **HiWork**,不是在 PMS(spec §3 D8)。
92
+ *
93
+ * ⚠️ 不再复用 `pms-client.ts` 的 `PMS_UNAUTHORIZED_MESSAGE`:那句还写着「请重新输入 PMS 账号密码」,
94
+ * 而密码通道正是本次要拆掉的东西——把它留给 PMS 网页端,桌面端一律引导重新登录 HiWork 客户端。
95
+ */
96
+ export declare const PMS_TODO_UNAUTHORIZED_MESSAGE = "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55 HiWork \u5BA2\u6237\u7AEF";
74
97
  /**
75
98
  * 权限不足的文案(spec §3 D7 逐字要求;`PmsError` 的 `FORBIDDEN` 原样抬到 RPC 面)。
76
99
  * UI 必须明说它,而不是静默失败或回滚得莫名其妙。
@@ -81,20 +104,24 @@ export declare const PMS_TODO_GENERIC_FAILURE_MESSAGE = "PMS \u6682\u65F6\u4E0D\
81
104
  /** 请求被取消的文案(客户端 abort;错误码仍走白名单里的 `BAD_REQUEST`)。 */
82
105
  export declare const PMS_TODO_CANCELLED_MESSAGE = "PMS \u5F85\u529E\u8BF7\u6C42\u5DF2\u53D6\u6D88\u3002";
83
106
  /**
84
- * `status` / `login` / `logout` 的返回值(spec §4.1)。
107
+ * `status` 的返回值(spec §4.3 / §4.4;字段名沿用 v1,语义重定义)。
108
+ *
109
+ * `configured` = **网关里存在 `slug='pms'` 的分配**(false → 找管理员,不给手工入口);
110
+ * `loggedIn` = **手上有一张未过期的 PMS JWT**(false 且 `configured` 为真时面板照常显示列表,
111
+ * 下一次取数会静默换一张)。
85
112
  *
86
- * `configured` = 「有账号」(值在 credentials 里或内存里);`loggedIn` = 「不用再问用户就能继续用」
87
- * (内存里有未过期的 access token,**或**手里有一个 refresh token)。refresh token 真失效时会在
88
- * 下一次取数时收敛成 `UNAUTHORIZED`——这里刻意不做提前探测:探测本身就是一次网络请求,而用户
89
- * 打开面板的动作已经会触发一次真实取数。
113
+ * `username`(来自 mint 响应,仅用于「已连接:109221」这类展示)与 `expiresAt`(ISO 8601,
114
+ * **不是** token 本身)都是给界面看的。`userId` 是「我的任务」的查询键(§3 D7)。
115
+ * `reason` 只在「还没就绪」时有值——它是面板选文案的依据,也是一次失败的最后结论
116
+ * (见 {@link PmsTodoStore.status}:status 自己**永不抛**)。
90
117
  */
91
118
  export interface PmsTodoStatus {
92
119
  configured: boolean;
93
120
  loggedIn: boolean;
94
- /** 已配置账号时的用户名(给 UI 展示「当前是哪个账号」)。 */
95
121
  username?: string;
96
- /** access token 的绝对过期时刻(ISO 8601)。 */
97
122
  expiresAt?: string;
123
+ userId?: number;
124
+ reason?: PmsTodoStatusReason;
98
125
  }
99
126
  /** `today` 的返回值(spec §4.1):条目 + 本次成功拉取的时刻。 */
100
127
  export interface PmsTodoToday {
@@ -140,6 +167,17 @@ export declare class PmsTodoRequestError extends Error {
140
167
  readonly code: "BAD_REQUEST";
141
168
  constructor(message: string);
142
169
  }
170
+ /**
171
+ * 本频道内部的凭据 / 会话错误(`NOT_CONFIGURED` / `UNAUTHORIZED` / `UPSTREAM_UNAVAILABLE`)。
172
+ *
173
+ * 用一个枚举样式的小类比裸字符串好:`PmsError` 只覆盖「上游说了什么」,而「没有 pms 分配」/
174
+ * 「没有网关会话」/「网关地址都没配」是 Host 自己的结论,两者混在一个类型里会让 `pmsTodoErrorOf`
175
+ * 不得不用字符串猜。
176
+ */
177
+ export declare class PmsTodoError extends Error {
178
+ readonly code: 'NOT_CONFIGURED' | 'UNAUTHORIZED' | 'UPSTREAM_UNAVAILABLE' | 'INTERNAL';
179
+ constructor(code: 'NOT_CONFIGURED' | 'UNAUTHORIZED' | 'UPSTREAM_UNAVAILABLE' | 'INTERNAL', message: string);
180
+ }
143
181
  /** Host 侧 RPC 依赖的最小上下文(结构兼容 cordis Context,便于单测伪造)。 */
144
182
  export interface PmsTodoRpcConnection {
145
183
  rpc: {
@@ -154,66 +192,70 @@ export interface PmsTodoRpcContext {
154
192
  warn(message: string): void;
155
193
  };
156
194
  }
157
- /** 凭据服务的子集(结构兼容 `ctx.credentials`,与 `CredentialsPort` 同形)。 */
195
+ /**
196
+ * 凭据服务的子集(结构兼容 `ctx.credentials`)。
197
+ *
198
+ * 只声明本频道真正用到的两个方法:`resolve` / `unset`(首启清理)。**没有 `set`**——令牌不落盘,
199
+ * 本模块不再往 credentials 里写任何东西(spec §4.5 的不落盘要求)。
200
+ */
158
201
  export interface PmsTodoCredentialsPort {
159
202
  resolve(ref: string): Promise<{
160
203
  value: string;
161
204
  } | undefined>;
162
- set(ref: string, value: string): Promise<void>;
163
205
  unset(ref: string): Promise<void>;
164
206
  }
165
207
  /**
166
- * PMS 客户端的子集:只声明本频道真正调用的七个方法(结构兼容 `PmsClient`)。
167
- * 端口化是单测能覆盖「refresh 失败时怎么做」这类分支的前提(真客户端要起 HTTP 服务)。
208
+ * 网关客户端的 PMS 子集:只声明本频道真正调用的两个方法(结构兼容 `AiRouterClient` / `GatewayClient`)。
209
+ *
210
+ * 与 `src/managed-mcp.ts` 读的是同两个接口,但两者**互不共用缓存**:那条链关心「装什么连接器」,
211
+ * 本链只关心「`pms` 那条分配在不在」。
212
+ */
213
+ export interface PmsTodoGatewayPort {
214
+ /** 管理员下发给当前账号的 MCP 清单(响应里没有令牌)。 */
215
+ getMyMcpAssignments(sessionCookie: string): Promise<GatewayMcpAssignmentsView>;
216
+ /** 为某条分配签一张 120 秒的身份断言(拿它去 mint 端点换 PMS JWT)。 */
217
+ issueMyMcpAssertion(sessionCookie: string, assignmentId: string): Promise<GatewayMcpAssertionTicket>;
218
+ }
219
+ /**
220
+ * PMS 客户端的子集:只声明本频道真正调用的六个方法(结构兼容 `PmsClient`)。
221
+ * 端口化是单测能覆盖「断言被拒 / mint 网络失败 / 令牌复用」这类分支的前提(真客户端要起 HTTP 服务)。
168
222
  */
169
223
  export interface PmsTodoPmsPort {
170
- login(username: string, password: string): Promise<PmsTokenSet>;
171
- refresh(refreshToken: string): Promise<PmsTokenSet>;
224
+ /** 120 秒断言 → 10 小时 PMS JWT(403/401 → `UNAUTHORIZED`,其余 → `UPSTREAM_UNAVAILABLE`)。 */
225
+ exchangeAssertion(mintUrl: string, assertion: string, deviceRef: string): Promise<PmsExchangeResult>;
172
226
  today(accessToken: string): Promise<PmsTodoItem[]>;
173
227
  statistics(accessToken: string): Promise<PmsTodoStatistics>;
174
228
  setStatus(accessToken: string, id: number, status: PmsTodoStatusValue): Promise<PmsTodoStatusResult>;
175
- /** §3 D7:我的未完成项目任务(`userId` = 凭据记录里的 PMS userId,**不由客户端传**)。 */
229
+ /** §3 D7:我的未完成项目任务(`userId` = mint 响应里的 PMS userId,**不由客户端传**)。 */
176
230
  myTasks(accessToken: string, userId: number, opts?: PmsMyTasksOptions): Promise<PmsTaskList>;
177
231
  /** §3 D7:完成一条项目任务(需要 `task:list:edit` 权限,否则 `PmsError('FORBIDDEN')`)。 */
178
232
  completeTask(accessToken: string, taskId: number): Promise<PmsTaskStatusResult>;
179
233
  }
180
- /** Host 侧依赖:凭据服务 + PMS 客户端 + 日志 + 时钟(全部可伪造)。 */
234
+ /**
235
+ * Host 侧依赖:会话解析 + 网关客户端 + 设备标识 + PMS 客户端 + 日志 + 时钟(全部可伪造)。
236
+ *
237
+ * 前三个的形状与 `src/index.ts` 的 `workbenchDeps` 逐字同形(同一副接线,少一处发明)。
238
+ */
181
239
  export interface PmsTodoRpcDeps {
182
- /** credentials 服务的三个方法。**读写都经它**,本模块不直接 import DSH 包。 */
183
- credentials: PmsTodoCredentialsPort;
240
+ /**
241
+ * 解析网关会话 cookie(`AI_ROUTER_SESSION`)。未登录 / 会话缺失返回 undefined。
242
+ * **返回值只在本模块内部使用,绝不出现在 RPC 响应、日志或任何缓存里。**
243
+ */
244
+ resolveSession(): Promise<string | undefined>;
245
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
246
+ gateway(): PmsTodoGatewayPort | undefined;
247
+ /** 设备标识(`resolveDeviceInfo(credentials).deviceId`):只作 mint 的 `deviceRef`,不参与鉴权。 */
248
+ resolveDeviceId(): Promise<string>;
184
249
  /** PMS HTTP 客户端(生产是 `new PmsClient()`)。 */
185
250
  pms: PmsTodoPmsPort;
186
- /** Host 日志出口(只记事件与错误类型,不记账号 / 密码 / token)。 */
251
+ /** Host 日志出口(只记事件、错误类型与 assignmentId,不记断言 / 令牌 / 会话 cookie)。 */
187
252
  log?: ((message: string) => void) | undefined;
188
253
  /** 时钟注入(测试用);缺省 `Date.now`。 */
189
254
  now?: (() => number) | undefined;
190
255
  }
191
- /**
192
- * 凭据记录(spec §4.4 的冻结值 + §3 D7 追加的 `userId`)。
193
- *
194
- * 落盘时序列化成 JSON;`password` 是**唯一**一个「不 remember 就不该出现在磁盘上」的字段
195
- * (见 {@link PmsTodoStore.persist})。`expiresAt` 是绝对时刻(epoch ms,不是给界面看的 ISO 串)
196
- * ——凭据记录是机器格式,少一层「解析出来的时刻对不对」的疑问。
197
- *
198
- * `userId`(§3 D7):登录响应里就有,是「我的任务」唯一的查询键(`assigneeId`)。它**不是凭据**
199
- * (PMS 内部的个人数字 id),所以:写盘时跟别的字段一样存;老记录没有它时 `myTasks` 收敛成
200
- * `UNAUTHORIZED`——用户重新登录一次即可拿到,不猜也不按用户名反查(那要额外的接口与权限)。
201
- */
202
- export interface PmsCredentialsRecord {
203
- username: string;
204
- password?: string;
205
- refreshToken?: string;
206
- expiresAt?: number;
207
- userId?: number;
208
- }
209
256
  /** 端点入参的解析结果(按端点分派的联合类型)。 */
210
257
  export type PmsTodoPayload = {
211
- readonly endpoint: 'status' | 'logout' | 'today' | 'statistics' | 'myTasks';
212
- } | {
213
- readonly endpoint: 'login';
214
- readonly username: string;
215
- readonly password: string;
216
- readonly remember: boolean;
258
+ readonly endpoint: 'status' | 'today' | 'statistics' | 'myTasks';
217
259
  } | {
218
260
  readonly endpoint: 'setStatus';
219
261
  readonly id: number;
@@ -231,7 +273,7 @@ export declare function isPmsTodoRpcEndpoint(endpoint: string): endpoint is PmsT
231
273
  * 而 `PENDING` / `COMPLETED` 是 PMS 侧唯一接受的两个值(其余的传过去只会得到一次业务失败)。
232
274
  * `completeTask` 的 `id` 同理(会被拼进**路径**,更要先收敛成正整数)。
233
275
  *
234
- * `myTasks` **不接受任何入参**(尤其不接受 `userId`):查询的「谁」必须由 Host 从凭据记录里取,
276
+ * `myTasks` **不接受任何入参**(尤其不接受 `userId`):查询的「谁」必须由 Host 从 mint 响应里取,
235
277
  * 否则客户端就能拿任意的 assigneeId 去查别人的任务——那正是 `PMS_TODO_RPC_ENDPOINTS` 白名单
236
278
  * 想守住的能力边界(§3 D6 / §3 D7)。
237
279
  */
@@ -246,78 +288,48 @@ export declare function pmsTodoErrorOf(error: unknown): PmsTodoFailure;
246
288
  */
247
289
  export declare function displayPmsTodoMessage(code: string, message: unknown): string;
248
290
  /**
249
- * 本频道内部的凭据/会话错误(`NOT_CONFIGURED` / `UNAUTHORIZED`)。
291
+ * 首启清理:抹掉 v1 落在 credentials 里的**明文 PMS 账号密码**(spec §3 D4 / §4.5)。
250
292
  *
251
- * 用一个枚举样式的小类比裸字符串好:`PmsError` 只覆盖「上游说了什么」,而「本地判断出来
252
- * 没有凭据 / 没有 refresh token」是 Host 自己的结论,两者混在一个类型里会让 `pmsTodoErrorOf`
253
- * 不得不用字符串猜。
254
- */
255
- export declare class PmsTodoError extends Error {
256
- readonly code: 'NOT_CONFIGURED' | 'UNAUTHORIZED' | 'INTERNAL';
257
- constructor(code: 'NOT_CONFIGURED' | 'UNAUTHORIZED' | 'INTERNAL', message: string);
258
- }
259
- /**
260
- * 解析凭据记录。
293
+ * 新链路没有任何凭据要落盘,所以这条记录之后就不该再存在——**幂等**(`resolve` 非空才 `unset`,
294
+ * 没有记录时一次写都不做)、**失败只记日志不抛**(清理失败绝不该拦住插件启动)。
295
+ * 调用方(`src/index.ts` 的 apply)只调一次;进程重启后再调也只会做一次没有记录的读。
261
296
  *
262
- * 记录是我们自己写的,但磁盘上的字节仍可能来自**旧版本的自己**,所以逐字段校验、
263
- * **未知字段忽略**(演进纪律),坏字段丢掉而不是让整条记录作废——一次 `format` 变更不该把用户
264
- * 变成「从未配置」。
297
+ * @param credentials - 凭据服务(只用到 `resolve` / `unset`)。
298
+ * @param log - Host 日志出口(只记事件与异常类型,不记 ref 里的值)。
265
299
  */
266
- export declare function parseCredentialsRecord(raw: string): PmsCredentialsRecord | undefined;
267
- /** 序列化凭据记录(**唯一**写盘路径)。缺字段不写键,避免把 `undefined` 变成 JSON 里的 `null`。 */
268
- export declare function serializeCredentialsRecord(record: PmsCredentialsRecord): string;
300
+ export declare function clearLegacyCredentials(credentials: PmsTodoCredentialsPort, log?: ((message: string) => void) | undefined): Promise<void>;
269
301
  /**
270
- * PMS 待办的状态仓库:持有凭据记录与 access token,按需续期,并暴露五个取数 / 命令动作
302
+ * PMS 待办的状态仓库:持有内存里的 PMS JWT,按分配 → 断言 → mint 拿令牌,并暴露五个取数 / 命令动作
271
303
  * (today / statistics / setStatus + §3 D7 的 myTasks / completeTask)。
272
304
  *
273
- * 一个刻意的分工:**凭据(账号密码 / refresh token)与 access token 只在本类里出现**。
274
- * 端点处理函数({@link handlePmsTodoEndpoint})只把返回值转发出去,因此「响应里没有 token」
275
- * 这件事是**结构上成立**的,而不是靠每一处返回值自觉。
305
+ * 一个刻意的分工:**断言与令牌只在本类里出现**。端点处理函数({@link handlePmsTodoEndpoint})
306
+ * 只把返回值转发出去,因此「响应里没有 token」这件事是**结构上成立**的,而不是靠每一处返回值自觉。
276
307
  */
277
308
  export declare class PmsTodoStore {
278
309
  private readonly deps;
279
- /** 内存里的凭据记录(不 remember 时它是这份凭据唯一的家)。 */
280
- private record;
281
- /** 是否已经从 credentials 服务读过一次(避免每次调用都读盘)。 */
282
- private loaded;
283
- /**
284
- * 当前会话是否**属于磁盘上的那条记录**(remember=true 的登录、或启动时从记录里恢复)。
285
- * 只有它为真时,refresh 换来的新 token 才回写记录——否则一次「不记住」的登录会顺手把
286
- * refresh token 写给下一个人(登出后磁盘上仍有可用凭据,这正是不 remember 的相反面)。
287
- */
288
- private persisted;
289
- /** access token(**只在内存**)。 */
290
- private access;
291
- /** 世代号:`login` / `logout` 各 +1,用来作废它之前发起的在飞请求(换账号的语义同工作台)。 */
292
- private generation;
293
- /** 在飞请求**连同世代与端点**记账:同一世代 + 同一端点才允许合并。 */
310
+ /** 内存里的会话(**唯一的家**,不落盘;重启后自然重换)。 */
311
+ private session;
312
+ /** 网关分配清单里那条 `pms` 分配(只在内存里记住,免得每次取数都拉一遍清单)。 */
313
+ private assignment;
314
+ /** 最近一次「为什么还没就绪」(进 `status` 的 `reason`;成功换到令牌时清空)。 */
315
+ private reason;
316
+ /** 在飞请求按端点记账:同一端点才允许合并。 */
294
317
  private inFlight;
295
318
  constructor(deps: PmsTodoRpcDeps);
296
319
  private now;
297
- /** 当前状态(同步语义的取数,异步实现:可能读一次 credentials)。**永不抛**。 */
298
- status(): Promise<PmsTodoStatus>;
299
320
  /**
300
- * 用账号密码登录并**换取** JWT。
321
+ * 当前状态(spec §4.4)。**永不抛**:失败在快照里表现为 `configured=false` + `reason`。
301
322
  *
302
- * ⚠️ **绝不自动重试**(spec §7 的硬要求):PMS 对连续失败登录有锁定策略,而「自动重试」正是
303
- * 最容易不小心连打两次的地方——用户手一抖打错一位密码,第二次尝试就会把账号推向锁定。
304
- * 所以本方法**只发一次**请求,失败即返回错误(UI 引导用户自己再输一次)。
305
- *
306
- * `remember` 为真才落盘(§4.4)。落盘写的是**完整**记录(含密码),因为下次启动时要靠它
307
- * 继续换 token——这不是「记住密码」的 UI 便利,而是 v1 认证方案本身(§3 D2)。
308
- */
309
- login(input: {
310
- username: string;
311
- password: string;
312
- remember: boolean;
313
- }): Promise<PmsTodoStatus>;
314
- /**
315
- * 登出:清内存 + 清凭据记录(spec §4.1 的 `logout`)。
323
+ * 顺序是刻意的:
324
+ * 1. 手上有一张未过期的 JWT → 直接报 `loggedIn`,**一次网络都不发**(面板每次挂载都读它);
325
+ * 2. 没有 → 问一次网关「到底有没有 `pms` 分配」。`configured` 的判据只能是它:用户填没填过
326
+ * 账号密码这件事已经不存在了(D3),而「有没有分配」只有网关知道。第二次起走内存里的那条
327
+ * 分配,不再打网关。
316
328
  *
317
- * 不清 PMS 侧的服务端会话:PMS 的 JWT 是无状态签发的(没有「撤销单个 token」的接口),
318
- * 我们能做且该做的是**不再持有**它——删掉本地凭据后,下一次取数一定要求重新输入。
329
+ * `reason` 只在「还没就绪」时出现:`no-assignment` / `gateway-signed-out` / `unavailable` 三种,
330
+ * 分别对应「找管理员」/「重新登录 HiWork」/「稍后重试」(spec §3 D8 的 UI 动作各不相同)。
319
331
  */
320
- logout(): Promise<PmsTodoStatus>;
332
+ status(): Promise<PmsTodoStatus>;
321
333
  /** `GET /api/todo/today` + 本次拉取时刻。同一时刻的并发调用合并成一次 PMS 请求。 */
322
334
  today(): Promise<PmsTodoToday>;
323
335
  /** `GET /api/todo/statistics`。并发调用同样合并。 */
@@ -336,13 +348,9 @@ export declare class PmsTodoStore {
336
348
  /**
337
349
  * `GET /api/task/tasks`:**我的未完成项目任务**(spec §3 D7)。
338
350
  *
339
- * `userId` 只从凭据记录里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
351
+ * `userId` 只从 mint 响应里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
340
352
  * 「只能查自己」唯一可靠的落点:端点白名单里也没有任何「按 id 查别人」的形状。
341
353
  *
342
- * 记录里**没有** `userId`(2026-09-18 之前登录的旧记录)时收敛成 `UNAUTHORIZED`:用户重新登录
343
- * 一次即可拿到它。这里刻意**不**回退成「按用户名去查用户列表」之类的动作——那需要额外的接口与
344
- * 权限,而且会在 Host 里凭空多一条拿别人数据的路径。
345
- *
346
354
  * 并发调用合并(同 `today`):这是**取数**,同一时刻问两遍没有意义。
347
355
  */
348
356
  myTasks(opts?: PmsMyTasksOptions): Promise<PmsTaskList>;
@@ -359,56 +367,74 @@ export declare class PmsTodoStore {
359
367
  id: number;
360
368
  }): Promise<PmsTaskStatusResult>;
361
369
  /**
362
- * 取一个能用的 access token(凭证获取的唯一入口,spec §3 D3 说 v2 只替换这里)。
370
+ * 取一个能用的 PMS JWT(凭据获取的**唯一入口**,spec §3 D10)。
363
371
  *
364
372
  * 顺序是刻意的:
365
- * 1. 内存里有未过期的 access token → 直接用(零网络请求);
366
- * 2. 过期了 → 先试 `refresh(refreshToken)`(§D2:refresh 有效就完全不必打扰用户);
367
- * refresh 成功会**顺手**把新的 access / refresh token 写回内存(原来是持久化的会话则一并落盘);
368
- * 3. refresh 失败(401 / 记录里根本没有 refresh token)→ `UNAUTHORIZED`,UI 要求重新输入。
373
+ * 1. 内存里有一张「剩余寿命 ≥ 50%」的令牌 → 直接用(零网络请求);
374
+ * 2. 否则:解析网关会话 → 从分配清单里取 `slug='pms'` 那条(D9)→ 签一张 120 秒断言 →
375
+ * 打分配的 mint 端点换一张 10 小时 JWT → 存回内存({@link PmsTodoStore.mint})。
369
376
  *
370
- * ⚠️ 这里**没有**「拿存下来的密码重新登录」这一支,而且是刻意的:§7 要求凭据失效时
371
- * **不自动重试**——用存下来的密码自动重登 = 每次 token 过期都自动替用户登一次,密码一旦改成
372
- * 别的,就会变成一条稳定的「定时用错密码打 PMS」的流水,直到账号被锁。密码只在用户**明确**
373
- * 提交一次 `login` 时才用(那时它也确实是用户刚输入的)。
377
+ * ⚠️ **断言绝不缓存**:它只有 120 秒、且 PMS 侧拿 `jti` 做一次性防重放,缓存下来只会是一张废纸。
378
+ * ⚠️ 这里**没有**「拿存下来的账号密码重新登一次」这一支,而且是结构上没有:账号密码通道已删
379
+ * (spec §3 D3),本文件里也搜不到任何密码字段(§7 的验收项)。
374
380
  */
375
381
  private authorize;
376
382
  /**
377
- * 状态投影(不读盘、不发网络)。
383
+ * 这张令牌还能不能接着用(spec §4.5:剩余寿命 ≥ 50% 才复用)。
378
384
  *
379
- * `expiresAt` **只报还没过期的那个时刻**:记录里那份可能是上一次会话留下的(进程重启后内存里
380
- * 没有 access token,只有磁盘上的旧 `expiresAt`),把它照实报出去,界面就会显示一个已经过去的
381
- * 「有效期至」,看着像还能用。过期了就不写这个键——refresh 会顺手把它换成新的。
385
+ * 判据与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `isCredentialReusable` 同口径:
386
+ * 分母是**这张令牌自己的寿命**(`expiresAt - issuedAt`),不是某个写死的 10 小时——服务端将来
387
+ * 改短有效期,复用口径跟着它走。
382
388
  */
383
- private statusOf;
389
+ private reusable;
390
+ /** 网关会话 cookie;缺失即 `UNAUTHORIZED`(动作在 HiWork:重新登录客户端)。 */
391
+ private sessionCookie;
392
+ /** 当前 baseURL 对应的网关客户端;没配置 baseURL 时同样是「去 HiWork 登录」这一类动作。 */
393
+ private gatewayPort;
384
394
  /**
385
- * 当前凭据记录:内存优先,首次调用时从 credentials 服务读一次。
395
+ * 取出这条客户端**被分配**的 `pms` 服务(spec §3 D9)。
386
396
  *
387
- * 「读一次就记住 `loaded`」是有意的:凭据的写入方只有本类(`login` / `logout` / refresh 回写),
388
- * 因此在同一进程里,读盘只需要一次。跨进程变更(别的进程写了同一个 ref)本就不可观测,
389
- * 硬要每次重新读只会让「不 remember 的登录」被磁盘上的旧记录当场覆盖。
397
+ * 匹配依据只有 slug(逐字等于 {@link PMS_ASSIGNMENT_SLUG}):清单里还有 visio-mcp 之类的分配,
398
+ * 「取第一条断言分配」在分配多起来之后会随机串台。**不看 `auth.kind`**:那是服务端演进的开放
399
+ * 集合,我们只认自己写死的 slug(D9 的原文就是「唯一匹配依据」)。
400
+ *
401
+ * 没有这条分配 → `NOT_CONFIGURED`:UI 提示「找管理员」,**不给任何手工配置入口**(spec §6)。
390
402
  */
391
- private currentRecord;
403
+ private ensureAssignment;
392
404
  /**
393
- * 落盘(**唯一**写 credentials 的地方,只在 remember 为真时被调用)。
405
+ * 签一张断言(120 秒、一次性),并取出它的 `mintUrl`。
406
+ *
407
+ * `mintUrl` 由**门票**给(`auth.mintUrl` 的权威副本在网关侧),本层不猜也不改写它。
408
+ */
409
+ private assertionTicket;
410
+ /**
411
+ * 断言 → 令牌(spec §4.1b 的客户端侧),失败语义按 §6 / plan 全局约束 2 分两类:
412
+ *
413
+ * - **被拒(401 / 403)→ 不重试**:断言过期 / `aud` 不符 / 签名不过 / 工号在 PMS 不存在,
414
+ * 四种原因在 PMS 侧都是同一个 403(防账号探测),重发只会连打;动作是「重新登录 HiWork」。
415
+ * - **网络 / 5xx → 换一张新断言重试一次**:断言可能是在途耗掉的(120 秒的门票),而同一张断言
416
+ * 是一次性的(`jti` 防重放),所以重试必须重新签一张。仍失败 → `UPSTREAM_UNAVAILABLE`。
394
417
  *
395
- * 失败**不**把一次成功的登录判为失败:会话已经拿到了,内存里照常可用;密码存不下的后果只是
396
- * 「重启后要重新输入」。反过来把登录判为失败会让用户以为密码错了。
418
+ * 日志只记 assignmentId 与事件(**不记断言、不记令牌**,spec §7)。
397
419
  */
398
- private persist;
420
+ private mint;
421
+ /** 设备标识(只进 PMS 侧审计、不参与鉴权):解析失败留空,不拦登录。 */
422
+ private deviceRef;
399
423
  /**
400
- * 抹掉磁盘上的记录(logout / 不记住的登录)。
424
+ * 网关异常 → 本频道的稳定错误(spec §3 D8)。
401
425
  *
402
- * 先 `resolve` 一次再 `unset`:没有记录时一次写都不做(测试与磁盘都清清爽爽),有记录时
403
- * **必须**清掉——否则一条旧的 account+refresh token 会在下次启动时被当成「这个人配置过凭据」,
404
- * 用户明明刚选了「不记住」却在重启后仍是登录态。
426
+ * 关键的分法:**会话失效**与**分配被撤销/停用**要分开。前者是用户自己的动作(重新登录 HiWork),
427
+ * 后者是管理员那边的动作(重新分配)——归到同一句话,用户就会对着一个自己修不了的问题反复重试。
405
428
  */
406
- private forgetStored;
429
+ private gatewayFailure;
430
+ /** 记下「为什么还没就绪」(只影响 `status` 的 `reason`,不影响错误码)。 */
431
+ private fail;
407
432
  /**
408
- * 并发合并:同一世代 + 同一端点的在飞请求共用一次调用与同一个返回值。
433
+ * 并发合并:同一端点的在飞请求共用一次调用与同一个返回值(面板挂载与手动刷新常常同拍)。
409
434
  *
410
- * 世代判据与 `src/client/managed-workbench.ts` 的 `dedupe` 同形:`login` / `logout` 之后
411
- * 账上那条属于**上一个人**,谁都不许复用(复用等于把上一个人的待办显示给新账号)。
435
+ * v1 还带一个「世代号」(`login` / `logout` 之后账上那条属于上一个人);现在**身份只有一个来源**
436
+ * (网关会话),进程内没有换人的入口,所以按端点名合并即可。换账号的清理由客户端半边的
437
+ * `clear()`(镜像)与会话本身负责。
412
438
  */
413
439
  private dedupe;
414
440
  }
@@ -416,8 +442,8 @@ export declare class PmsTodoStore {
416
442
  * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
417
443
  * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
418
444
  *
419
- * 日志纪律:只记**端点名 + 异常类型**。入参里有 `password`(`login`),返回值里有 access token 的
420
- * 派生状态,两者都不许进日志(§7)。
445
+ * 日志纪律:只记**端点名 + 异常类型**。返回值里有会话的派生状态(过期时刻 / 账号名 / userId),
446
+ * 令牌与断言本身一律不进日志(§7)。
421
447
  */
422
448
  export declare function handlePmsTodoEndpoint(endpoint: string, rawPayload: unknown, deps: PmsTodoRpcDeps, store: PmsTodoStore, signal?: AbortSignal): Promise<PmsTodoRpcResult<unknown>>;
423
449
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hiwork-core",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "description": "HiWork 企业统一登录与模型自动供给插件(ai-router 网关登录,Web 表单,无 CLI 认证)",
6
6
  "license": "UNLICENSED",