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/index.js CHANGED
@@ -3137,7 +3137,7 @@ function isUnloading(ctx) {
3137
3137
  }
3138
3138
 
3139
3139
  // src/version.ts
3140
- var PKG_VERSION = false ? "dev" : "0.0.22";
3140
+ var PKG_VERSION = false ? "dev" : "0.0.24";
3141
3141
 
3142
3142
  // src/ai-router-client.ts
3143
3143
  var AUTH_AUTHORIZE_PATH = "/api/auth/authorize";
@@ -5448,8 +5448,6 @@ function registerWorkbenchRpc(ctx, deps, store) {
5448
5448
 
5449
5449
  // src/pms-client.ts
5450
5450
  var PMS_BASE_URL = "https://pms.hivery.cn";
5451
- var AUTH_LOGIN_PATH = "/api/auth/login";
5452
- var AUTH_REFRESH_PATH = "/api/auth/refresh";
5453
5451
  var TODO_TODAY_PATH = "/api/todo/today";
5454
5452
  var TODO_STATISTICS_PATH = "/api/todo/statistics";
5455
5453
  var TASK_LIST_PATH = "/api/task/tasks";
@@ -5501,26 +5499,60 @@ var PmsClient = class {
5501
5499
  this.log = options.log;
5502
5500
  }
5503
5501
  /**
5504
- * `POST /api/auth/login`:用账号密码换 JWT(access + refresh)。
5502
+ * `POST {mintUrl}`:用一张网关签发的 **120 秒身份断言**换一把 **10 小时 PMS JWT**
5503
+ * (spec `2026-09-21-pms-zero-input-login-design.md` §4.1b)。
5505
5504
  *
5506
- * 失败语义(由 {@link PmsClient.call} 统一给出):
5507
- * - 账号密码不对 → `UNAUTHORIZED`(HTTP 401 **或** 信封 `code:401`,两者同义);
5508
- * - 其余(网络 / 超时 / 5xx / 非业务信封)→ `UPSTREAM_UNAVAILABLE`。
5509
- * **本方法不重试**,调用方也不许替它重试(§7:避免用错密码连续触发 PMS 的失败锁定)。
5510
- */
5511
- async login(username, password) {
5512
- const envelope = await this.call(AUTH_LOGIN_PATH, { method: "POST", body: { username, password } });
5513
- return this.tokenSet(envelope.data);
5514
- }
5515
- /**
5516
- * `POST /api/auth/refresh`:用 refresh token 续一次 access token。
5505
+ * 与其余方法的两处形状差异都是契约给定的,不是本层偷懒:
5506
+ * - **`mintUrl` 是绝对地址**,来自网关的连接器分配(`auth.mintUrl`)——它**不拼** {@link PMS_BASE_URL}:
5507
+ * 那个常量是「PMS 部署地址」,而兑换端点由网关的分配清单说了算(本层不猜也不改写,见
5508
+ * {@link PmsClient.send});
5509
+ * - **不解 `Result` 信封**:成功体就是 `{token, expiresAt, userId, username}`,失败体是
5510
+ * `{error:"assertion_rejected"}` 这类——两者都不是业务信封。
5511
+ *
5512
+ * 失败语义:
5513
+ * - HTTP 401 / 403(断言过期、`aud` 不符、签名不过、工号在 PMS 不存在)→ `UNAUTHORIZED`:用户该做的
5514
+ * 是**重新登录 HiWork 客户端**(断言来自网关会话),不是再输一次 PMS 密码(spec D8);
5515
+ * - 其余(网络 / 超时 / 400 / 503 / 非 JSON / 2xx 却形状不符)→ `UPSTREAM_UNAVAILABLE`。
5517
5516
  *
5518
- * refresh token 失效(7 天到期 / 已被 PMS 撤销)→ `UNAUTHORIZED`,此时**只能**重新输入账号密码;
5519
- * 网络问题 → `UPSTREAM_UNAVAILABLE`(那是「稍后重试」,不是「要重新登录」——两者对用户是两件事)。
5517
+ * **本方法不重试**:断言是一次性的(PMS 侧拿 `jti` 做 2 分钟防重放),要重试必须由调用方**换一张新
5518
+ * 断言**再调——重发同一张只会换来第二个 403(spec §6)。
5519
+ *
5520
+ * @param mintUrl - 网关分配里的 `auth.mintUrl`(绝对 URL)。
5521
+ * @param assertion - 网关签发的短命断言(**不进日志、不落盘**,用完即弃)。
5522
+ * @param deviceRef - 设备标识串(可空;只进 PMS 侧审计,不参与鉴权)。
5523
+ * @returns 令牌与它的元数据(`token` 交给 Host 内存,见 `src/pms-todo-bridge.ts`)。
5520
5524
  */
5521
- async refresh(refreshToken) {
5522
- const envelope = await this.call(AUTH_REFRESH_PATH, { method: "POST", body: { refreshToken } });
5523
- return this.tokenSet(envelope.data);
5525
+ async exchangeAssertion(mintUrl, assertion, deviceRef) {
5526
+ const response = await this.send(mintUrl, { method: "POST", body: { assertion, deviceRef } });
5527
+ if (response.status === 401 || response.status === 403) {
5528
+ this.log?.(`hiwork-core: PMS \u6362\u4EE4\u724C\u88AB\u62D2\uFF08HTTP ${response.status}\uFF09`);
5529
+ throw new PmsError("UNAUTHORIZED", PMS_UNAUTHORIZED_MESSAGE, response.status);
5530
+ }
5531
+ if (response.status < 200 || response.status >= 300) {
5532
+ this.log?.(`hiwork-core: PMS \u6362\u4EE4\u724C\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`);
5533
+ throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE, response.status);
5534
+ }
5535
+ const payload = await readJson2(response);
5536
+ if (!isRecord3(payload)) {
5537
+ this.log?.("hiwork-core: PMS \u6362\u4EE4\u724C\u8FD4\u56DE\u4E86\u975E JSON \u54CD\u5E94\uFF08\u53EF\u80FD\u7684\u4E2D\u95F4\u5C42\uFF09");
5538
+ throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE, response.status);
5539
+ }
5540
+ const token = textOf(payload["token"]);
5541
+ if (token === void 0) {
5542
+ this.log?.("hiwork-core: PMS \u6362\u4EE4\u724C\u54CD\u5E94\u7F3A token\uFF08\u5951\u7EA6\u7834\u574F\uFF09\uFF0C\u6309\u4E0A\u6E38\u4E0D\u53EF\u7528\u5904\u7406");
5543
+ throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE, response.status);
5544
+ }
5545
+ if (!isInteger(payload["userId"])) {
5546
+ this.log?.("hiwork-core: PMS \u6362\u4EE4\u724C\u54CD\u5E94\u7F3A userId / \u5F62\u6001\u4E0D\u5BF9\uFF08\u5951\u7EA6\u7834\u574F\uFF09\uFF0C\u6309\u4E0A\u6E38\u4E0D\u53EF\u7528\u5904\u7406");
5547
+ throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE, response.status);
5548
+ }
5549
+ return {
5550
+ token,
5551
+ // 两个非关键字段按容错处理:令牌已经拿到了,不为一个坏字段让用户重来一次。
5552
+ expiresAt: expiresAtOr(payload["expiresAt"], this.now() + PMS_ACCESS_TOKEN_TTL_MS),
5553
+ userId: payload["userId"],
5554
+ username: textOf(payload["username"]) ?? ""
5555
+ };
5524
5556
  }
5525
5557
  /**
5526
5558
  * `GET /api/todo/today`:今日待办(⚠️ 口径比名字宽:**今天 + 明天 + 无截止日期**都算,spec §D4)。
@@ -5656,15 +5688,18 @@ var PmsClient = class {
5656
5688
  };
5657
5689
  }
5658
5690
  /**
5659
- * 发一次调用并解开 `Result` 信封。**所有错误都在这里收敛**,返回值只有 `data`。
5691
+ * 发一次请求:拼地址、塞请求头、按 {@link PmsClientOptions.timeoutMs} 超时。**只有传输**。
5660
5692
  *
5661
- * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
5662
- * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
5663
- * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
5664
- * 误判成「上游故障」而让用户不知道要重新输入密码。
5693
+ * 与 {@link PmsClient.call} 的分工:`call` 负责 `Result` 信封与业务码,本方法不管响应体是什么形状
5694
+ * ——换令牌那条路({@link PmsClient.exchangeAssertion})既不解信封也不认业务码,但**超时口径与请求头
5695
+ * 必须与其余调用逐字相同**(spec §7:统一 10s、令牌只在有身份时发),所以两条路共用这一处,
5696
+ * 而不是各抄一份、将来各自漂移。
5697
+ *
5698
+ * @param url - 相对路径(拼 {@link PmsClient.baseURL})或**绝对 URL**(原样使用)。
5699
+ * @param opts - 方法 / 可选 JSON 体 / 可选 Bearer 令牌。
5700
+ * @returns 原始 `Response`:状态码由调用方判定,本方法不把任何状态码当成错误。
5665
5701
  */
5666
- async call(path, opts) {
5667
- let response;
5702
+ async send(url, opts) {
5668
5703
  try {
5669
5704
  const headers = {
5670
5705
  "user-agent": `hiwork-core/${PKG_VERSION}`,
@@ -5678,10 +5713,22 @@ var PmsClient = class {
5678
5713
  signal: AbortSignal.timeout(this.timeoutMs)
5679
5714
  };
5680
5715
  if (opts.body !== void 0) init.body = JSON.stringify(opts.body);
5681
- response = await fetch(`${this.baseURL}${path}`, init);
5716
+ return await fetch(isAbsoluteUrl(url) ? url : `${this.baseURL}${url}`, init);
5682
5717
  } catch {
5683
5718
  throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE);
5684
5719
  }
5720
+ }
5721
+ /**
5722
+ * 发一次调用并解开 `Result` 信封。**PMS 的业务错误都在这里收敛**,返回值只有 `data`
5723
+ * (传输层的失败已在 {@link PmsClient.send} 收敛)。
5724
+ *
5725
+ * 顺序是刻意的:**先看 HTTP 401,再解信封**——PMS 未登录时回的是 Spring Security 形状
5726
+ * (`{path, code:"AUTH_001", message}`),它的 `message` 是给浏览器看跳登录页的,既不是业务文案
5727
+ * 也可能带 `path` 等实现细节。把它当业务信封解析,轻则文案变成一句英文,重则把「凭据失效」
5728
+ * 误判成「上游故障」而让用户不知道要重新输入密码。
5729
+ */
5730
+ async call(path, opts) {
5731
+ const response = await this.send(path, opts);
5685
5732
  if (response.status === 401) {
5686
5733
  throw new PmsError("UNAUTHORIZED", PMS_UNAUTHORIZED_MESSAGE, 401);
5687
5734
  }
@@ -5713,32 +5760,17 @@ var PmsClient = class {
5713
5760
  }
5714
5761
  return { code, data: payload["data"] };
5715
5762
  }
5716
- /** 登录 / 刷新的 `data` → {@link PmsTokenSet}。200 却不给 token 视为契约破坏(上游不可用)。 */
5717
- tokenSet(data) {
5718
- if (!isRecord3(data)) {
5719
- this.log?.("hiwork-core: PMS \u8BA4\u8BC1\u54CD\u5E94\u7F3A data\uFF08\u5951\u7EA6\u7834\u574F\uFF09\uFF0C\u6309\u4E0A\u6E38\u4E0D\u53EF\u7528\u5904\u7406");
5720
- throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE);
5721
- }
5722
- const accessToken = textOf(data["token"]);
5723
- if (accessToken === void 0) {
5724
- this.log?.("hiwork-core: PMS \u8BA4\u8BC1\u54CD\u5E94\u7F3A token\uFF08\u5951\u7EA6\u7834\u574F\uFF09\uFF0C\u6309\u4E0A\u6E38\u4E0D\u53EF\u7528\u5904\u7406");
5725
- throw new PmsError("UPSTREAM_UNAVAILABLE", PMS_GENERIC_FAILURE_MESSAGE);
5726
- }
5727
- const refreshToken = textOf(data["refreshToken"]);
5728
- const expiresIn = data["expiresIn"];
5729
- const ttlMs = typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : PMS_ACCESS_TOKEN_TTL_MS;
5730
- const userId = isInteger(data["userId"]) ? data["userId"] : void 0;
5731
- return {
5732
- accessToken,
5733
- ...refreshToken === void 0 ? {} : { refreshToken },
5734
- ...userId === void 0 ? {} : { userId },
5735
- expiresAt: this.now() + ttlMs
5736
- };
5737
- }
5738
5763
  };
5739
5764
  function isRecord3(value) {
5740
5765
  return typeof value === "object" && value !== null && !Array.isArray(value);
5741
5766
  }
5767
+ function isAbsoluteUrl(url) {
5768
+ return /^https?:\/\//i.test(url);
5769
+ }
5770
+ function expiresAtOr(value, fallbackMs) {
5771
+ if (typeof value === "string" && value !== "" && Number.isFinite(Date.parse(value))) return value;
5772
+ return new Date(fallbackMs).toISOString();
5773
+ }
5742
5774
  function textOf(value) {
5743
5775
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
5744
5776
  }
@@ -5831,18 +5863,18 @@ async function readJson2(response) {
5831
5863
  var PMS_TODO_RPC_CHANNEL = "/hiwork-pms-todo";
5832
5864
  var PMS_TODO_RPC_ENDPOINTS = [
5833
5865
  "status",
5834
- "login",
5835
- "logout",
5836
5866
  "today",
5837
5867
  "statistics",
5838
5868
  "setStatus",
5839
5869
  "myTasks",
5840
5870
  "completeTask"
5841
5871
  ];
5872
+ var PMS_ASSIGNMENT_SLUG = "pms";
5842
5873
  var PMS_CREDENTIALS_REF = "HIWORK_PMS_CREDENTIALS";
5843
5874
  var PMS_TODO_ACCESS_TOKEN_TTL_MS = 10 * 60 * 60 * 1e3;
5844
- var PMS_TODO_NOT_CONFIGURED_MESSAGE = "\u5C1A\u672A\u914D\u7F6E PMS \u8D26\u53F7\uFF0C\u8BF7\u5148\u8F93\u5165\u8D26\u53F7\u5BC6\u7801";
5845
- var PMS_TODO_UNAUTHORIZED_MESSAGE = PMS_UNAUTHORIZED_MESSAGE;
5875
+ var PMS_TODO_REUSE_MIN_REMAINING_RATIO = 0.5;
5876
+ var 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";
5877
+ var PMS_TODO_UNAUTHORIZED_MESSAGE = "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55 HiWork \u5BA2\u6237\u7AEF";
5846
5878
  var PMS_TODO_FORBIDDEN_MESSAGE = PMS_FORBIDDEN_MESSAGE;
5847
5879
  var PMS_TODO_GENERIC_FAILURE_MESSAGE = PMS_GENERIC_FAILURE_MESSAGE;
5848
5880
  var PMS_TODO_CANCELLED_MESSAGE = "PMS \u5F85\u529E\u8BF7\u6C42\u5DF2\u53D6\u6D88\u3002";
@@ -5853,9 +5885,19 @@ var PmsTodoRequestError = class extends Error {
5853
5885
  this.name = "PmsTodoRequestError";
5854
5886
  }
5855
5887
  };
5888
+ var PmsTodoError = class extends Error {
5889
+ constructor(code, message) {
5890
+ super(message);
5891
+ this.code = code;
5892
+ this.name = "PmsTodoError";
5893
+ }
5894
+ };
5856
5895
  function isPmsTodoRpcEndpoint(endpoint) {
5857
5896
  return PMS_TODO_RPC_ENDPOINTS.includes(endpoint);
5858
5897
  }
5898
+ function textOf2(value) {
5899
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
5900
+ }
5859
5901
  function asRecord(endpoint, raw) {
5860
5902
  if (raw === void 0 || raw === null) return {};
5861
5903
  if (typeof raw !== "object" || Array.isArray(raw)) {
@@ -5872,24 +5914,10 @@ function rejectUnknownKeys(endpoint, record, allowed) {
5872
5914
  }
5873
5915
  function parsePmsTodoPayload(endpoint, raw) {
5874
5916
  const record = asRecord(endpoint, raw);
5875
- if (endpoint === "status" || endpoint === "logout" || endpoint === "today" || endpoint === "statistics" || endpoint === "myTasks") {
5917
+ if (endpoint === "status" || endpoint === "today" || endpoint === "statistics" || endpoint === "myTasks") {
5876
5918
  rejectUnknownKeys(endpoint, record, []);
5877
5919
  return { endpoint };
5878
5920
  }
5879
- if (endpoint === "login") {
5880
- rejectUnknownKeys(endpoint, record, ["username", "password", "remember"]);
5881
- const username = typeof record["username"] === "string" ? record["username"].trim() : "";
5882
- if (username === "") throw new PmsTodoRequestError("PMS \u767B\u5F55\u8BF7\u6C42\u7F3A\u5C11 username\u3002");
5883
- const password = record["password"];
5884
- if (typeof password !== "string" || password === "") {
5885
- throw new PmsTodoRequestError("PMS \u767B\u5F55\u8BF7\u6C42\u7F3A\u5C11 password\u3002");
5886
- }
5887
- const remember = record["remember"];
5888
- if (remember !== void 0 && typeof remember !== "boolean") {
5889
- throw new PmsTodoRequestError("PMS \u767B\u5F55\u8BF7\u6C42\u7684 remember \u5FC5\u987B\u662F\u5E03\u5C14\u503C\u3002");
5890
- }
5891
- return { endpoint, username, password, remember: remember === true };
5892
- }
5893
5921
  if (endpoint === "completeTask") {
5894
5922
  rejectUnknownKeys(endpoint, record, ["id"]);
5895
5923
  const id2 = record["id"];
@@ -5937,139 +5965,95 @@ function displayPmsTodoMessage(code, message) {
5937
5965
  if (leaksImplementationDetail) return PMS_TODO_MESSAGE_FALLBACK[code] ?? PMS_TODO_GENERIC_FAILURE_MESSAGE;
5938
5966
  return text;
5939
5967
  }
5940
- var PmsTodoError = class extends Error {
5941
- constructor(code, message) {
5942
- super(message);
5943
- this.code = code;
5944
- this.name = "PmsTodoError";
5945
- }
5946
- };
5947
5968
  function errorName(error) {
5948
5969
  return error instanceof Error ? error.name : typeof error;
5949
5970
  }
5950
- function parseCredentialsRecord(raw) {
5951
- let parsed;
5971
+ function gatewayCodeOf(error) {
5972
+ return error instanceof GatewayError ? error.code : errorName(error);
5973
+ }
5974
+ async function clearLegacyCredentials(credentials, log) {
5952
5975
  try {
5953
- parsed = JSON.parse(raw);
5954
- } catch {
5955
- return void 0;
5976
+ const hit = await credentials.resolve(PMS_CREDENTIALS_REF);
5977
+ if (hit === void 0 || hit.value === "") return;
5978
+ await credentials.unset(PMS_CREDENTIALS_REF);
5979
+ log?.("hiwork-core: \u5DF2\u6E05\u9664 v1 \u9057\u7559\u7684 PMS \u51ED\u636E\u8BB0\u5F55");
5980
+ } catch (error) {
5981
+ log?.(`hiwork-core: PMS \u9057\u7559\u51ED\u636E\u6E05\u9664\u5931\u8D25\uFF08${errorName(error)}\uFF09`);
5956
5982
  }
5957
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
5958
- const record = parsed;
5959
- const username = typeof record["username"] === "string" ? record["username"].trim() : "";
5960
- if (username === "") return void 0;
5961
- const password = typeof record["password"] === "string" && record["password"] !== "" ? record["password"] : void 0;
5962
- const refreshToken = typeof record["refreshToken"] === "string" && record["refreshToken"] !== "" ? record["refreshToken"] : void 0;
5963
- const expiresAt = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) ? record["expiresAt"] : void 0;
5964
- const userId = typeof record["userId"] === "number" && Number.isSafeInteger(record["userId"]) ? record["userId"] : void 0;
5983
+ }
5984
+ function sessionOf(result, now) {
5985
+ const parsed = Date.parse(result.expiresAt);
5965
5986
  return {
5966
- username,
5967
- ...password === void 0 ? {} : { password },
5968
- ...refreshToken === void 0 ? {} : { refreshToken },
5969
- ...expiresAt === void 0 ? {} : { expiresAt },
5970
- ...userId === void 0 ? {} : { userId }
5987
+ token: result.token,
5988
+ // `PmsClient` 已保证 `expiresAt` 可解析(缺失时它自己兜底成 now + 10 小时);这里再兜一次
5989
+ // 是为了「上游回了一个解析不了的串」时不至于把过期时刻变成 NaN(那会让复用判据永远为假)。
5990
+ expiresAt: Number.isFinite(parsed) ? parsed : now + PMS_TODO_ACCESS_TOKEN_TTL_MS,
5991
+ issuedAt: now,
5992
+ userId: result.userId,
5993
+ username: result.username
5971
5994
  };
5972
5995
  }
5973
- function serializeCredentialsRecord(record) {
5974
- return JSON.stringify({
5975
- username: record.username,
5976
- ...record.password === void 0 ? {} : { password: record.password },
5977
- ...record.refreshToken === void 0 ? {} : { refreshToken: record.refreshToken },
5978
- ...record.expiresAt === void 0 ? {} : { expiresAt: record.expiresAt },
5979
- ...record.userId === void 0 ? {} : { userId: record.userId }
5980
- });
5981
- }
5982
5996
  var PmsTodoStore = class {
5983
5997
  constructor(deps) {
5984
5998
  this.deps = deps;
5985
5999
  }
5986
- /** 内存里的凭据记录(不 remember 时它是这份凭据唯一的家)。 */
5987
- record;
5988
- /** 是否已经从 credentials 服务读过一次(避免每次调用都读盘)。 */
5989
- loaded = false;
5990
- /**
5991
- * 当前会话是否**属于磁盘上的那条记录**(remember=true 的登录、或启动时从记录里恢复)。
5992
- * 只有它为真时,refresh 换来的新 token 才回写记录——否则一次「不记住」的登录会顺手把
5993
- * refresh token 写给下一个人(登出后磁盘上仍有可用凭据,这正是不 remember 的相反面)。
5994
- */
5995
- persisted = false;
5996
- /** access token(**只在内存**)。 */
5997
- access;
5998
- /** 世代号:`login` / `logout` 各 +1,用来作废它之前发起的在飞请求(换账号的语义同工作台)。 */
5999
- generation = 0;
6000
- /** 在飞请求**连同世代与端点**记账:同一世代 + 同一端点才允许合并。 */
6000
+ /** 内存里的会话(**唯一的家**,不落盘;重启后自然重换)。 */
6001
+ session;
6002
+ /** 网关分配清单里那条 `pms` 分配(只在内存里记住,免得每次取数都拉一遍清单)。 */
6003
+ assignment;
6004
+ /** 最近一次「为什么还没就绪」(进 `status` 的 `reason`;成功换到令牌时清空)。 */
6005
+ reason;
6006
+ /** 在飞请求按端点记账:同一端点才允许合并。 */
6001
6007
  inFlight;
6002
6008
  now() {
6003
6009
  return this.deps.now?.() ?? Date.now();
6004
6010
  }
6005
- /** 当前状态(同步语义的取数,异步实现:可能读一次 credentials)。**永不抛**。 */
6006
- async status() {
6007
- const record = await this.currentRecord();
6008
- return this.statusOf(record);
6009
- }
6010
6011
  /**
6011
- * 用账号密码登录并**换取** JWT。
6012
+ * 当前状态(spec §4.4)。**永不抛**:失败在快照里表现为 `configured=false` + `reason`。
6012
6013
  *
6013
- * ⚠️ **绝不自动重试**(spec §7 的硬要求):PMS 对连续失败登录有锁定策略,而「自动重试」正是
6014
- * 最容易不小心连打两次的地方——用户手一抖打错一位密码,第二次尝试就会把账号推向锁定。
6015
- * 所以本方法**只发一次**请求,失败即返回错误(UI 引导用户自己再输一次)。
6014
+ * 顺序是刻意的:
6015
+ * 1. 手上有一张未过期的 JWT → 直接报 `loggedIn`,**一次网络都不发**(面板每次挂载都读它);
6016
+ * 2. 没有 → 问一次网关「到底有没有 `pms` 分配」。`configured` 的判据只能是它:用户填没填过
6017
+ * 账号密码这件事已经不存在了(D3),而「有没有分配」只有网关知道。第二次起走内存里的那条
6018
+ * 分配,不再打网关。
6016
6019
  *
6017
- * `remember` 为真才落盘(§4.4)。落盘写的是**完整**记录(含密码),因为下次启动时要靠它
6018
- * 继续换 token——这不是「记住密码」的 UI 便利,而是 v1 认证方案本身(§3 D2)。
6020
+ * `reason` 只在「还没就绪」时出现:`no-assignment` / `gateway-signed-out` / `unavailable` 三种,
6021
+ * 分别对应「找管理员」/「重新登录 HiWork」/「稍后重试」(spec §3 D8 的 UI 动作各不相同)。
6019
6022
  */
6020
- async login(input) {
6021
- const tokens = await this.deps.pms.login(input.username, input.password);
6022
- const record = {
6023
- username: input.username,
6024
- password: input.password,
6025
- ...tokens.refreshToken === void 0 ? {} : { refreshToken: tokens.refreshToken },
6026
- expiresAt: tokens.expiresAt,
6027
- // §3 D7:登录响应里的 `userId` 必须**在这里**捕获——「我的任务」要用它当 `assigneeId`,
6028
- // 而除了登录/刷新没有别的地方能拿到它(不猜、不按用户名反查)。
6029
- ...tokens.userId === void 0 ? {} : { userId: tokens.userId }
6030
- };
6031
- this.generation += 1;
6032
- this.record = record;
6033
- this.loaded = true;
6034
- this.access = { token: tokens.accessToken, expiresAt: tokens.expiresAt };
6035
- this.persisted = input.remember;
6036
- if (input.remember) {
6037
- await this.persist(record);
6038
- } else {
6039
- await this.forgetStored();
6023
+ async status() {
6024
+ const session = this.session;
6025
+ if (session !== void 0 && this.now() < session.expiresAt) {
6026
+ return {
6027
+ configured: true,
6028
+ loggedIn: true,
6029
+ username: session.username,
6030
+ expiresAt: new Date(session.expiresAt).toISOString(),
6031
+ userId: session.userId,
6032
+ ...this.reason === void 0 ? {} : { reason: this.reason }
6033
+ };
6034
+ }
6035
+ try {
6036
+ const cookie = await this.sessionCookie();
6037
+ const gateway = this.gatewayPort();
6038
+ await this.ensureAssignment(gateway, cookie);
6039
+ return { configured: true, loggedIn: false, ...this.reason === void 0 ? {} : { reason: this.reason } };
6040
+ } catch {
6041
+ return { configured: false, loggedIn: false, reason: this.reason ?? "unavailable" };
6040
6042
  }
6041
- this.deps.log?.("hiwork-core: PMS \u8D26\u53F7\u5DF2\u767B\u5F55\uFF08\u51ED\u636E\u53EA\u5B58\u4E8E Host\uFF09");
6042
- return this.statusOf(record);
6043
- }
6044
- /**
6045
- * 登出:清内存 + 清凭据记录(spec §4.1 的 `logout`)。
6046
- *
6047
- * 不清 PMS 侧的服务端会话:PMS 的 JWT 是无状态签发的(没有「撤销单个 token」的接口),
6048
- * 我们能做且该做的是**不再持有**它——删掉本地凭据后,下一次取数一定要求重新输入。
6049
- */
6050
- async logout() {
6051
- this.generation += 1;
6052
- this.record = void 0;
6053
- this.loaded = true;
6054
- this.persisted = false;
6055
- this.access = void 0;
6056
- await this.forgetStored();
6057
- this.deps.log?.("hiwork-core: PMS \u51ED\u636E\u5DF2\u6E05\u9664");
6058
- return this.statusOf(void 0);
6059
6043
  }
6060
6044
  /** `GET /api/todo/today` + 本次拉取时刻。同一时刻的并发调用合并成一次 PMS 请求。 */
6061
6045
  async today() {
6062
6046
  return await this.dedupe("today", async () => {
6063
- const token = await this.authorize();
6064
- const items = await this.deps.pms.today(token);
6047
+ const session = await this.authorize();
6048
+ const items = await this.deps.pms.today(session.token);
6065
6049
  return { items, fetchedAt: new Date(this.now()).toISOString() };
6066
6050
  });
6067
6051
  }
6068
6052
  /** `GET /api/todo/statistics`。并发调用同样合并。 */
6069
6053
  async statistics() {
6070
6054
  return await this.dedupe("statistics", async () => {
6071
- const token = await this.authorize();
6072
- return await this.deps.pms.statistics(token);
6055
+ const session = await this.authorize();
6056
+ return await this.deps.pms.statistics(session.token);
6073
6057
  });
6074
6058
  }
6075
6059
  /**
@@ -6080,30 +6064,21 @@ var PmsTodoStore = class {
6080
6064
  * 的顺序处理」——那个语义 PMS 也不提供。
6081
6065
  */
6082
6066
  async setStatus(input) {
6083
- const token = await this.authorize();
6084
- return await this.deps.pms.setStatus(token, input.id, input.status);
6067
+ const session = await this.authorize();
6068
+ return await this.deps.pms.setStatus(session.token, input.id, input.status);
6085
6069
  }
6086
6070
  /**
6087
6071
  * `GET /api/task/tasks`:**我的未完成项目任务**(spec §3 D7)。
6088
6072
  *
6089
- * `userId` 只从凭据记录里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
6073
+ * `userId` 只从 mint 响应里取(客户端传不进来,见 `parsePmsTodoPayload` 的注释)——这是
6090
6074
  * 「只能查自己」唯一可靠的落点:端点白名单里也没有任何「按 id 查别人」的形状。
6091
6075
  *
6092
- * 记录里**没有** `userId`(2026-09-18 之前登录的旧记录)时收敛成 `UNAUTHORIZED`:用户重新登录
6093
- * 一次即可拿到它。这里刻意**不**回退成「按用户名去查用户列表」之类的动作——那需要额外的接口与
6094
- * 权限,而且会在 Host 里凭空多一条拿别人数据的路径。
6095
- *
6096
6076
  * 并发调用合并(同 `today`):这是**取数**,同一时刻问两遍没有意义。
6097
6077
  */
6098
6078
  async myTasks(opts = {}) {
6099
6079
  return await this.dedupe("myTasks", async () => {
6100
- const token = await this.authorize();
6101
- const userId = (await this.currentRecord())?.userId;
6102
- if (userId === void 0) {
6103
- this.deps.log?.("hiwork-core: PMS \u51ED\u636E\u8BB0\u5F55\u91CC\u6CA1\u6709 userId\uFF0C\u6211\u7684\u4EFB\u52A1\u9700\u8981\u91CD\u65B0\u767B\u5F55\u4E00\u6B21");
6104
- throw new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6105
- }
6106
- return await this.deps.pms.myTasks(token, userId, opts);
6080
+ const session = await this.authorize();
6081
+ return await this.deps.pms.myTasks(session.token, session.userId, opts);
6107
6082
  });
6108
6083
  }
6109
6084
  /**
@@ -6116,132 +6091,203 @@ var PmsTodoStore = class {
6116
6091
  * UI 按 §3 D7 提示「请到 PMS 操作」——**不是**重新登录。
6117
6092
  */
6118
6093
  async completeTask(input) {
6119
- const token = await this.authorize();
6120
- return await this.deps.pms.completeTask(token, input.id);
6094
+ const session = await this.authorize();
6095
+ return await this.deps.pms.completeTask(session.token, input.id);
6121
6096
  }
6122
6097
  /**
6123
- * 取一个能用的 access token(凭证获取的唯一入口,spec §3 D3 说 v2 只替换这里)。
6098
+ * 取一个能用的 PMS JWT(凭据获取的**唯一入口**,spec §3 D10)。
6124
6099
  *
6125
6100
  * 顺序是刻意的:
6126
- * 1. 内存里有未过期的 access token → 直接用(零网络请求);
6127
- * 2. 过期了 → 先试 `refresh(refreshToken)`(§D2:refresh 有效就完全不必打扰用户);
6128
- * refresh 成功会**顺手**把新的 access / refresh token 写回内存(原来是持久化的会话则一并落盘);
6129
- * 3. refresh 失败(401 / 记录里根本没有 refresh token)→ `UNAUTHORIZED`,UI 要求重新输入。
6101
+ * 1. 内存里有一张「剩余寿命 ≥ 50%」的令牌 → 直接用(零网络请求);
6102
+ * 2. 否则:解析网关会话 → 从分配清单里取 `slug='pms'` 那条(D9)→ 签一张 120 秒断言 →
6103
+ * 打分配的 mint 端点换一张 10 小时 JWT → 存回内存({@link PmsTodoStore.mint})。
6130
6104
  *
6131
- * ⚠️ 这里**没有**「拿存下来的密码重新登录」这一支,而且是刻意的:§7 要求凭据失效时
6132
- * **不自动重试**——用存下来的密码自动重登 = 每次 token 过期都自动替用户登一次,密码一旦改成
6133
- * 别的,就会变成一条稳定的「定时用错密码打 PMS」的流水,直到账号被锁。密码只在用户**明确**
6134
- * 提交一次 `login` 时才用(那时它也确实是用户刚输入的)。
6105
+ * ⚠️ **断言绝不缓存**:它只有 120 秒、且 PMS 侧拿 `jti` 做一次性防重放,缓存下来只会是一张废纸。
6106
+ * ⚠️ 这里**没有**「拿存下来的账号密码重新登一次」这一支,而且是结构上没有:账号密码通道已删
6107
+ * (spec §3 D3),本文件里也搜不到任何密码字段(§7 的验收项)。
6135
6108
  */
6136
6109
  async authorize() {
6137
- const access = this.access;
6138
- if (access !== void 0 && this.now() < access.expiresAt) return access.token;
6139
- const record = await this.currentRecord();
6140
- if (record === void 0) {
6141
- throw new PmsTodoError("NOT_CONFIGURED", PMS_TODO_NOT_CONFIGURED_MESSAGE);
6142
- }
6143
- const refreshToken = record.refreshToken;
6144
- if (refreshToken === void 0) {
6145
- throw new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6146
- }
6147
- const tokens = await this.deps.pms.refresh(refreshToken);
6148
- const next = {
6149
- ...record,
6150
- ...tokens.refreshToken === void 0 ? {} : { refreshToken: tokens.refreshToken },
6151
- expiresAt: tokens.expiresAt
6152
- };
6153
- this.record = next;
6154
- this.access = { token: tokens.accessToken, expiresAt: tokens.expiresAt };
6155
- if (this.persisted) await this.persist(next);
6156
- return tokens.accessToken;
6110
+ const cached = this.session;
6111
+ if (cached !== void 0 && this.reusable(cached)) return cached;
6112
+ const cookie = await this.sessionCookie();
6113
+ const gateway = this.gatewayPort();
6114
+ const assignment = await this.ensureAssignment(gateway, cookie);
6115
+ return await this.mint(gateway, cookie, assignment);
6157
6116
  }
6158
6117
  /**
6159
- * 状态投影(不读盘、不发网络)。
6118
+ * 这张令牌还能不能接着用(spec §4.5:剩余寿命 ≥ 50% 才复用)。
6160
6119
  *
6161
- * `expiresAt` **只报还没过期的那个时刻**:记录里那份可能是上一次会话留下的(进程重启后内存里
6162
- * 没有 access token,只有磁盘上的旧 `expiresAt`),把它照实报出去,界面就会显示一个已经过去的
6163
- * 「有效期至」,看着像还能用。过期了就不写这个键——refresh 会顺手把它换成新的。
6120
+ * 判据与 `hiwork-capabilities/src/connectors/assignments.ts` 的 `isCredentialReusable` 同口径:
6121
+ * 分母是**这张令牌自己的寿命**(`expiresAt - issuedAt`),不是某个写死的 10 小时——服务端将来
6122
+ * 改短有效期,复用口径跟着它走。
6164
6123
  */
6165
- statusOf(record) {
6124
+ reusable(session) {
6166
6125
  const now = this.now();
6167
- const access = this.access;
6168
- const hasAccess = access !== void 0 && now < access.expiresAt;
6169
- const hasRefresh = record?.refreshToken !== void 0 && record.refreshToken !== "";
6170
- const expiresAt = access?.expiresAt ?? record?.expiresAt;
6171
- const freshExpiry = expiresAt !== void 0 && expiresAt > now ? expiresAt : void 0;
6172
- return {
6173
- configured: record !== void 0,
6174
- loggedIn: hasAccess || hasRefresh,
6175
- ...record === void 0 ? {} : { username: record.username },
6176
- ...freshExpiry === void 0 ? {} : { expiresAt: new Date(freshExpiry).toISOString() }
6177
- };
6126
+ if (now >= session.expiresAt) return false;
6127
+ const lifetime = session.expiresAt - session.issuedAt;
6128
+ if (lifetime <= 0) return false;
6129
+ return session.expiresAt - now >= lifetime * PMS_TODO_REUSE_MIN_REMAINING_RATIO;
6130
+ }
6131
+ /** 网关会话 cookie;缺失即 `UNAUTHORIZED`(动作在 HiWork:重新登录客户端)。 */
6132
+ async sessionCookie() {
6133
+ const cookie = await this.deps.resolveSession();
6134
+ if (cookie === void 0 || cookie === "") {
6135
+ this.fail("gateway-signed-out");
6136
+ throw new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6137
+ }
6138
+ return cookie;
6139
+ }
6140
+ /** 当前 baseURL 对应的网关客户端;没配置 baseURL 时同样是「去 HiWork 登录」这一类动作。 */
6141
+ gatewayPort() {
6142
+ const gateway = this.deps.gateway();
6143
+ if (gateway === void 0) {
6144
+ this.fail("gateway-signed-out");
6145
+ throw new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6146
+ }
6147
+ return gateway;
6178
6148
  }
6179
6149
  /**
6180
- * 当前凭据记录:内存优先,首次调用时从 credentials 服务读一次。
6150
+ * 取出这条客户端**被分配**的 `pms` 服务(spec §3 D9)。
6151
+ *
6152
+ * 匹配依据只有 slug(逐字等于 {@link PMS_ASSIGNMENT_SLUG}):清单里还有 visio-mcp 之类的分配,
6153
+ * 「取第一条断言分配」在分配多起来之后会随机串台。**不看 `auth.kind`**:那是服务端演进的开放
6154
+ * 集合,我们只认自己写死的 slug(D9 的原文就是「唯一匹配依据」)。
6181
6155
  *
6182
- * 「读一次就记住 `loaded`」是有意的:凭据的写入方只有本类(`login` / `logout` / refresh 回写),
6183
- * 因此在同一进程里,读盘只需要一次。跨进程变更(别的进程写了同一个 ref)本就不可观测,
6184
- * 硬要每次重新读只会让「不 remember 的登录」被磁盘上的旧记录当场覆盖。
6156
+ * 没有这条分配 → `NOT_CONFIGURED`:UI 提示「找管理员」,**不给任何手工配置入口**(spec §6)。
6185
6157
  */
6186
- async currentRecord() {
6187
- if (this.loaded) return this.record;
6188
- this.loaded = true;
6158
+ async ensureAssignment(gateway, cookie) {
6159
+ const cached = this.assignment;
6160
+ if (cached !== void 0) return cached;
6161
+ let view;
6189
6162
  try {
6190
- const hit = await this.deps.credentials.resolve(PMS_CREDENTIALS_REF);
6191
- if (hit === void 0 || hit.value === "") return void 0;
6192
- const record = parseCredentialsRecord(hit.value);
6193
- this.record = record;
6194
- this.persisted = record !== void 0;
6195
- return record;
6163
+ view = await gateway.getMyMcpAssignments(cookie);
6196
6164
  } catch (error) {
6197
- this.deps.log?.(`hiwork-core: PMS \u51ED\u636E\u8BFB\u53D6\u5931\u8D25\uFF08${errorName(error)}\uFF09`);
6198
- return void 0;
6165
+ throw this.gatewayFailure(error);
6166
+ }
6167
+ const list = Array.isArray(view?.assignments) ? view.assignments : [];
6168
+ for (const item of list) {
6169
+ if (item?.slug !== PMS_ASSIGNMENT_SLUG) continue;
6170
+ const assignmentId = textOf2(item.assignmentId);
6171
+ if (assignmentId === void 0) continue;
6172
+ const assignment = { assignmentId };
6173
+ this.assignment = assignment;
6174
+ this.reason = void 0;
6175
+ return assignment;
6199
6176
  }
6177
+ this.fail("no-assignment");
6178
+ throw new PmsTodoError("NOT_CONFIGURED", PMS_TODO_NOT_CONFIGURED_MESSAGE);
6200
6179
  }
6201
6180
  /**
6202
- * 落盘(**唯一**写 credentials 的地方,只在 remember 为真时被调用)。
6181
+ * 签一张断言(120 秒、一次性),并取出它的 `mintUrl`。
6203
6182
  *
6204
- * 失败**不**把一次成功的登录判为失败:会话已经拿到了,内存里照常可用;密码存不下的后果只是
6205
- * 「重启后要重新输入」。反过来把登录判为失败会让用户以为密码错了。
6183
+ * `mintUrl` 由**门票**给(`auth.mintUrl` 的权威副本在网关侧),本层不猜也不改写它。
6206
6184
  */
6207
- async persist(record) {
6185
+ async assertionTicket(gateway, cookie, assignment) {
6186
+ let ticket;
6208
6187
  try {
6209
- await this.deps.credentials.set(PMS_CREDENTIALS_REF, serializeCredentialsRecord(record));
6210
- this.persisted = true;
6188
+ ticket = await gateway.issueMyMcpAssertion(cookie, assignment.assignmentId);
6211
6189
  } catch (error) {
6212
- this.deps.log?.(`hiwork-core: PMS \u51ED\u636E\u843D\u76D8\u5931\u8D25\uFF08${errorName(error)}\uFF09`);
6190
+ throw this.gatewayFailure(error);
6213
6191
  }
6192
+ const assertion = textOf2(ticket?.assertion);
6193
+ const mintUrl = textOf2(ticket?.mintUrl);
6194
+ if (assertion === void 0 || mintUrl === void 0) {
6195
+ this.deps.log?.("hiwork-core: PMS \u53D6\u65AD\u8A00\u6536\u5230\u65E0\u6548\u54CD\u5E94\uFF08\u7F3A assertion / mintUrl\uFF09");
6196
+ this.fail("unavailable");
6197
+ throw new PmsTodoError("INTERNAL", PMS_TODO_GENERIC_FAILURE_MESSAGE);
6198
+ }
6199
+ return { assertion, mintUrl };
6214
6200
  }
6215
6201
  /**
6216
- * 抹掉磁盘上的记录(logout / 不记住的登录)。
6202
+ * 断言 → 令牌(spec §4.1b 的客户端侧),失败语义按 §6 / plan 全局约束 2 分两类:
6203
+ *
6204
+ * - **被拒(401 / 403)→ 不重试**:断言过期 / `aud` 不符 / 签名不过 / 工号在 PMS 不存在,
6205
+ * 四种原因在 PMS 侧都是同一个 403(防账号探测),重发只会连打;动作是「重新登录 HiWork」。
6206
+ * - **网络 / 5xx → 换一张新断言重试一次**:断言可能是在途耗掉的(120 秒的门票),而同一张断言
6207
+ * 是一次性的(`jti` 防重放),所以重试必须重新签一张。仍失败 → `UPSTREAM_UNAVAILABLE`。
6217
6208
  *
6218
- * 先 `resolve` 一次再 `unset`:没有记录时一次写都不做(测试与磁盘都清清爽爽),有记录时
6219
- * **必须**清掉——否则一条旧的 account+refresh token 会在下次启动时被当成「这个人配置过凭据」,
6220
- * 用户明明刚选了「不记住」却在重启后仍是登录态。
6209
+ * 日志只记 assignmentId 与事件(**不记断言、不记令牌**,spec §7)。
6221
6210
  */
6222
- async forgetStored() {
6211
+ async mint(gateway, cookie, assignment) {
6212
+ const deviceRef = await this.deviceRef();
6213
+ let attempt = 0;
6214
+ while (attempt < 2) {
6215
+ attempt += 1;
6216
+ const ticket = await this.assertionTicket(gateway, cookie, assignment);
6217
+ try {
6218
+ const session = sessionOf(
6219
+ await this.deps.pms.exchangeAssertion(ticket.mintUrl, ticket.assertion, deviceRef),
6220
+ this.now()
6221
+ );
6222
+ this.session = session;
6223
+ this.reason = void 0;
6224
+ return session;
6225
+ } catch (error) {
6226
+ if (error instanceof PmsError && error.code === "UNAUTHORIZED") {
6227
+ this.deps.log?.(`hiwork-core: PMS \u6362\u4EE4\u724C\u88AB\u62D2\uFF08\u5206\u914D ${assignment.assignmentId}\uFF09`);
6228
+ this.fail("gateway-signed-out");
6229
+ throw new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6230
+ }
6231
+ if (error instanceof PmsError && error.code === "UPSTREAM_UNAVAILABLE" && attempt === 1) {
6232
+ this.deps.log?.(`hiwork-core: PMS \u6362\u4EE4\u724C\u5931\u8D25\uFF08\u5206\u914D ${assignment.assignmentId}\uFF09\uFF0C\u6362\u4E00\u5F20\u65B0\u65AD\u8A00\u91CD\u8BD5\u4E00\u6B21`);
6233
+ continue;
6234
+ }
6235
+ this.fail("unavailable");
6236
+ throw error instanceof PmsError ? error : new PmsTodoError("INTERNAL", PMS_TODO_GENERIC_FAILURE_MESSAGE);
6237
+ }
6238
+ }
6239
+ this.fail("unavailable");
6240
+ throw new PmsTodoError("UPSTREAM_UNAVAILABLE", PMS_TODO_GENERIC_FAILURE_MESSAGE);
6241
+ }
6242
+ /** 设备标识(只进 PMS 侧审计、不参与鉴权):解析失败留空,不拦登录。 */
6243
+ async deviceRef() {
6223
6244
  try {
6224
- const hit = await this.deps.credentials.resolve(PMS_CREDENTIALS_REF);
6225
- if (hit === void 0 || hit.value === "") return;
6226
- await this.deps.credentials.unset(PMS_CREDENTIALS_REF);
6245
+ return await this.deps.resolveDeviceId();
6227
6246
  } catch (error) {
6228
- this.deps.log?.(`hiwork-core: PMS \u51ED\u636E\u6E05\u9664\u5931\u8D25\uFF08${errorName(error)}\uFF09`);
6247
+ this.deps.log?.(`hiwork-core: \u8BBE\u5907\u6807\u8BC6\u89E3\u6790\u5931\u8D25\uFF08${errorName(error)}\uFF09\uFF0CdeviceRef \u7559\u7A7A`);
6248
+ return "";
6249
+ }
6250
+ }
6251
+ /**
6252
+ * 网关异常 → 本频道的稳定错误(spec §3 D8)。
6253
+ *
6254
+ * 关键的分法:**会话失效**与**分配被撤销/停用**要分开。前者是用户自己的动作(重新登录 HiWork),
6255
+ * 后者是管理员那边的动作(重新分配)——归到同一句话,用户就会对着一个自己修不了的问题反复重试。
6256
+ */
6257
+ gatewayFailure(error) {
6258
+ this.deps.log?.(`hiwork-core: PMS \u7F51\u5173\u8C03\u7528\u5931\u8D25\uFF08${gatewayCodeOf(error)}\uFF09`);
6259
+ if (error instanceof GatewayError) {
6260
+ if (error.code === "SESSION_EXPIRED") {
6261
+ this.fail("gateway-signed-out");
6262
+ return new PmsTodoError("UNAUTHORIZED", PMS_TODO_UNAUTHORIZED_MESSAGE);
6263
+ }
6264
+ if (error.code === "MCP_ASSIGNMENT_GONE" || error.code === "MCP_NOT_DELIVERABLE") {
6265
+ this.assignment = void 0;
6266
+ this.fail("no-assignment");
6267
+ return new PmsTodoError("NOT_CONFIGURED", PMS_TODO_NOT_CONFIGURED_MESSAGE);
6268
+ }
6229
6269
  }
6270
+ this.fail("unavailable");
6271
+ return new PmsTodoError("UPSTREAM_UNAVAILABLE", PMS_TODO_GENERIC_FAILURE_MESSAGE);
6272
+ }
6273
+ /** 记下「为什么还没就绪」(只影响 `status` 的 `reason`,不影响错误码)。 */
6274
+ fail(reason) {
6275
+ this.reason = reason;
6230
6276
  }
6231
6277
  /**
6232
- * 并发合并:同一世代 + 同一端点的在飞请求共用一次调用与同一个返回值。
6278
+ * 并发合并:同一端点的在飞请求共用一次调用与同一个返回值(面板挂载与手动刷新常常同拍)。
6233
6279
  *
6234
- * 世代判据与 `src/client/managed-workbench.ts` 的 `dedupe` 同形:`login` / `logout` 之后
6235
- * 账上那条属于**上一个人**,谁都不许复用(复用等于把上一个人的待办显示给新账号)。
6280
+ * v1 还带一个「世代号」(`login` / `logout` 之后账上那条属于上一个人);现在**身份只有一个来源**
6281
+ * (网关会话),进程内没有换人的入口,所以按端点名合并即可。换账号的清理由客户端半边的
6282
+ * `clear()`(镜像)与会话本身负责。
6236
6283
  */
6237
6284
  async dedupe(key, task) {
6238
6285
  const inFlight = this.inFlight;
6239
- if (inFlight !== void 0 && inFlight.generation === this.generation && inFlight.key === key) {
6286
+ if (inFlight !== void 0 && inFlight.key === key) {
6240
6287
  return await inFlight.task;
6241
6288
  }
6242
- const generation = this.generation;
6243
6289
  const promise = task();
6244
- this.inFlight = { generation, key, task: promise };
6290
+ this.inFlight = { key, task: promise };
6245
6291
  try {
6246
6292
  return await promise;
6247
6293
  } finally {
@@ -6259,10 +6305,6 @@ async function handlePmsTodoEndpoint(endpoint, rawPayload, deps, store, signal)
6259
6305
  switch (payload.endpoint) {
6260
6306
  case "status":
6261
6307
  return { ok: true, value: await store.status() };
6262
- case "login":
6263
- return { ok: true, value: await store.login({ username: payload.username, password: payload.password, remember: payload.remember }) };
6264
- case "logout":
6265
- return { ok: true, value: await store.logout() };
6266
6308
  case "today":
6267
6309
  return { ok: true, value: await store.today() };
6268
6310
  case "statistics":
@@ -7194,7 +7236,12 @@ function apply(ctx, config) {
7194
7236
  };
7195
7237
  }, "hiwork-core: workbench warmup");
7196
7238
  const pmsTodoDeps = {
7197
- credentials: ports.credentials,
7239
+ resolveSession: async () => (await ports.credentials.resolve(SESSION_REF))?.value,
7240
+ gateway: () => {
7241
+ const baseURL = current().baseURL;
7242
+ return baseURL === void 0 || baseURL === "" ? void 0 : gatewayFor(baseURL);
7243
+ },
7244
+ resolveDeviceId: async () => (await resolveDeviceInfo(ports.credentials)).deviceId,
7198
7245
  pms: new PmsClient({ log: (message) => {
7199
7246
  ctx.logger.warn(message);
7200
7247
  } }),
@@ -7207,6 +7254,9 @@ function apply(ctx, config) {
7207
7254
  () => registerPmsTodoRpc({ connection, logger: ctx.logger }, pmsTodoDeps, pmsTodo),
7208
7255
  "hiwork-core: pms todo rpc"
7209
7256
  );
7257
+ void clearLegacyCredentials(ports.credentials, (message) => {
7258
+ ctx.logger.warn(message);
7259
+ });
7210
7260
  ctx.effect(() => provideOfficeCredentials(ctx, {
7211
7261
  resolveSession: async () => (await ports.credentials.resolve(SESSION_REF))?.value,
7212
7262
  gateway: () => {