@mrrisega/dsh-remote 0.5.0 → 0.6.0-beta.0

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,11 +1,12 @@
1
- // dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-web 更名;卸载/清理兼容旧名)
1
+ // dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-ui 更名 dsh-remote-web;卸载/清理兼容旧名)
2
2
  //
3
- // 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的配置面板调用:
4
- // - 读写 dsh-remote-open/.dsh-config.json(0600)
3
+ // 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的「远程访问」设置面板调用:
4
+ // - 读写配置目录下 .dsh-config.json(0600)
5
5
  // - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
6
- // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
6
+ // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理;
7
+ // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke,Bearer)供面板「📱 远程访问」卡使用
7
8
  // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
8
- // 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge);
9
+ // 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
9
10
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
10
11
  // 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
11
12
  // - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
@@ -378,7 +379,7 @@ function ensureRuntime(relayDir) {
378
379
  try {
379
380
  mkdirSync(relayDir, { recursive: true });
380
381
  const log = join(relayDir, AUTO_INSTALL_LOG);
381
- const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
382
+ const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
382
383
  detached: true,
383
384
  env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
384
385
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
@@ -698,6 +699,109 @@ async function relayInviteRecords(relayDir) {
698
699
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
699
700
  }
700
701
 
702
+ // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
703
+
704
+ /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
705
+ function relayErrorMessage(r) {
706
+ const b = r && typeof r === "object" ? r.body : null;
707
+ if (b && typeof b === "object") {
708
+ if (typeof b.error === "string" && b.error) return b.error;
709
+ if (b.error && typeof b.error === "object") {
710
+ if (typeof b.error.message === "string" && b.error.message) return b.error.message;
711
+ }
712
+ if (typeof b.message === "string" && b.message) return b.message;
713
+ }
714
+ if (typeof b === "string" && b.trim()) return b.trim();
715
+ const st = r && r.status;
716
+ return st ? `企业端请求失败(HTTP ${st})` : "企业端不可达,请稍后重试";
717
+ }
718
+
719
+ /** 未登录(无账号/自建密钥)时的统一返回文案。 */
720
+ function notLoggedInJson() {
721
+ return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
722
+ }
723
+
724
+ /**
725
+ * 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
726
+ * 返回扁平对象;数组字段只取首层数组。
727
+ */
728
+ function flattenRelayBody(r) {
729
+ const b = r && typeof r === "object" && r.body && typeof r.body === "object" ? r.body : {};
730
+ const d = b.data && typeof b.data === "object" ? { ...b.data, ...b } : b;
731
+ return d;
732
+ }
733
+
734
+ /**
735
+ * GET /dsh-remote/access-key → 创建一次性访问密钥(企业端 POST /api/auth-key,Bearer device-login token)。
736
+ * 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
737
+ */
738
+ async function proxyCreateAccessKey(relayDir, res) {
739
+ const token = await relayToken(relayDir).catch(() => "");
740
+ if (!token) return sendJson(res, 401, notLoggedInJson());
741
+ const r = await relayFetch(relayDir, "/api/auth-key", {
742
+ method: "POST",
743
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
744
+ body: JSON.stringify({}),
745
+ });
746
+ const d = flattenRelayBody(r);
747
+ const url = typeof d.url === "string" ? d.url.trim() : "";
748
+ if (!r.ok || !url) {
749
+ // url 不可用是硬失败(企业端契约缺陷);错误码/状态透传给 UI 展示
750
+ const err = r.ok && !url ? "企业端未返回可用的访问地址(缺 url)" : relayErrorMessage(r);
751
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: err, relayStatus: (r && r.status) || 0 });
752
+ }
753
+ return sendJson(res, 200, {
754
+ ok: true,
755
+ url,
756
+ key: d.key ?? null,
757
+ expires_at: d.expires_at ?? null,
758
+ ttl_ms: d.ttl_ms ?? null,
759
+ qr_data_url: d.qr_data_url ?? null,
760
+ relayStatus: (r && r.status) || 200,
761
+ });
762
+ }
763
+
764
+ /**
765
+ * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)。
766
+ */
767
+ async function proxyMobileSessions(relayDir, res) {
768
+ const token = await relayToken(relayDir).catch(() => "");
769
+ if (!token) return sendJson(res, 401, notLoggedInJson());
770
+ const r = await relayFetch(relayDir, "/api/mobile-sessions", {
771
+ method: "POST",
772
+ headers: { authorization: `Bearer ${token}` },
773
+ });
774
+ const d = flattenRelayBody(r);
775
+ const sessions = Array.isArray(d.sessions) ? d.sessions : [];
776
+ if (!r.ok) {
777
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0, sessions });
778
+ }
779
+ return sendJson(res, 200, { ok: true, sessions, relayStatus: (r && r.status) || 200 });
780
+ }
781
+
782
+ /**
783
+ * POST /dsh-remote/mobile-sessions/revoke(body {id})→ 取消配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)。
784
+ */
785
+ async function proxyRevokeMobileSession(relayDir, req, res) {
786
+ const body = await readJsonBody(req);
787
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
788
+ const id = String(body.id ?? "").trim();
789
+ if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
790
+ const token = await relayToken(relayDir).catch(() => "");
791
+ if (!token) return sendJson(res, 401, notLoggedInJson());
792
+ const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
793
+ method: "POST",
794
+ headers: { authorization: `Bearer ${token}` },
795
+ });
796
+ const d = flattenRelayBody(r);
797
+ // 兼容两种成功形态:HTTP ok,或 body.ok === true(允许企业端 200 + ok:false 表示业务失败)
798
+ const ok = !!(r.ok && d.ok !== false);
799
+ if (!ok) {
800
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
801
+ }
802
+ return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
803
+ }
804
+
701
805
  // ---------- 综合状态 ----------
702
806
 
703
807
  async function composeStatus(relayDir) {
@@ -842,27 +946,35 @@ const PLUGIN_ID = "dsh-remote-web";
842
946
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
843
947
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
844
948
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
845
- const PLUGIN_VERSION = "0.5.0";
949
+ const PLUGIN_VERSION = "0.6.0-beta.0";
846
950
  const UPDATE_LOG = ".dsh-update.log";
847
951
  const UPDATE_MARKER = ".dsh-update-running";
848
952
 
849
- /** 查询 npm 最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
953
+ /**
954
+ * 更新通道(发布策略):普通用户只拉稳定 dist-tag `latest`;预发(alpha/beta)由作者/内测
955
+ * 通过 `DSH_UPDATE_TAG=beta`(或显式版本号)拉取。迭代一律先发 beta/alpha,稳定后才升 latest。
956
+ */
957
+ const UPDATE_TAG = (process.env.DSH_UPDATE_TAG || "latest").replace(/^@/, "");
958
+ const UPDATE_SPEC = `@mrrisega/dsh-remote@${UPDATE_TAG}`;
959
+
960
+ /** 查询所选通道(npm dist-tag)最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
850
961
  async function npmLatestVersion() {
851
962
  for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
852
963
  try {
853
964
  const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
854
965
  if (!res.ok) continue;
855
966
  const j = await res.json();
856
- if (j && j["dist-tags"] && typeof j["dist-tags"].latest === "string") return j["dist-tags"].latest;
967
+ const tags = j && j["dist-tags"] ? j["dist-tags"] : {};
968
+ if (typeof tags[UPDATE_TAG] === "string") return tags[UPDATE_TAG];
857
969
  } catch { /* 试下一个源 */ }
858
970
  }
859
971
  return "";
860
972
  }
861
973
 
862
- /** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
974
+ /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
863
975
  function spawnUpdater(relayDir, extraEnv) {
864
976
  const log = join(relayDir, UPDATE_LOG);
865
- return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
977
+ return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
866
978
  detached: true,
867
979
  cwd: homedir(),
868
980
  env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
@@ -871,7 +983,7 @@ function spawnUpdater(relayDir, extraEnv) {
871
983
  }
872
984
 
873
985
  /**
874
- * 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/收敛 include)。
986
+ * 后台执行在线一键更新:npx 按 dist-tag(默认 latest)(幂等自愈:补运行环境/更新 bridge/收敛 include)。
875
987
  * 稳健性:
876
988
  * - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
877
989
  * - 【官方源优先】镜像(npmmirror)滞后时会把旧版(如 0.4.4)当成最新安装,旧 pluginCmd 会把
@@ -884,7 +996,7 @@ function runOnlineUpdate(relayDir) {
884
996
  mkdirSync(relayDir, { recursive: true });
885
997
  const marker = join(relayDir, UPDATE_MARKER);
886
998
  if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
887
- appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
999
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
888
1000
 
889
1001
  let retried = false;
890
1002
  const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
@@ -1030,7 +1142,7 @@ function registerRoutes(ctx, relayDir) {
1030
1142
  if (rt.removedPlist) bits.push("自启动项已删除");
1031
1143
  if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
1032
1144
  if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
1033
- bits.push("请重启 dsh web 后完全卸载生效(本插件与远程控制将消失);如需再次使用,在插件市场重新安装即可。");
1145
+ bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
1034
1146
  sendJson(res, 200, {
1035
1147
  ok: true,
1036
1148
  ...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
@@ -1086,6 +1198,30 @@ function registerRoutes(ctx, relayDir) {
1086
1198
  });
1087
1199
  },
1088
1200
  },
1201
+ // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
1202
+ {
1203
+ method: "GET",
1204
+ path: "/dsh-remote/access-key",
1205
+ handler: async (_req, res) => {
1206
+ await proxyCreateAccessKey(relayDir, res);
1207
+ },
1208
+ },
1209
+ // 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)
1210
+ {
1211
+ method: "GET",
1212
+ path: "/dsh-remote/mobile-sessions",
1213
+ handler: async (_req, res) => {
1214
+ await proxyMobileSessions(relayDir, res);
1215
+ },
1216
+ },
1217
+ // 取消已授权设备配对(企业端 POST /api/mobile-sessions/:id/revoke,Bearer)
1218
+ {
1219
+ method: "POST",
1220
+ path: "/dsh-remote/mobile-sessions/revoke",
1221
+ handler: async (req, res) => {
1222
+ await proxyRevokeMobileSession(relayDir, req, res);
1223
+ },
1224
+ },
1089
1225
  {
1090
1226
  method: "POST",
1091
1227
  path: "/dsh-remote/config",
@@ -0,0 +1,211 @@
1
+ // 插件 node 半新增代理回归:/dsh-remote/access-key | mobile-sessions | mobile-sessions/revoke
2
+ // (企业端 E1 契约:POST /api/auth-key、POST /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke,Bearer device-login JWT)
3
+ // 覆盖:Bearer 透传、字段扁平化、未登录 401、qr 缺失容错、上游错误透传。
4
+ import assert from "node:assert/strict";
5
+ import http from "node:http";
6
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import test from "node:test";
10
+ import { apply } from "../lib/index.js";
11
+
12
+ const EXPIRES = 1893456000000;
13
+ const QR = "data:image/png;base64,AAAA";
14
+
15
+ /** 假企业端:记录收到的请求(method/path/authorization),按场景回包。 */
16
+ function startFakeRelay(opts = {}) {
17
+ const seen = [];
18
+ const srv = http.createServer(async (req, res) => {
19
+ const url = new URL(req.url, "http://x");
20
+ const send = (code, obj) => {
21
+ res.writeHead(code, { "content-type": "application/json" });
22
+ res.end(JSON.stringify(obj));
23
+ };
24
+ const rec = { method: req.method, path: url.pathname, authorization: req.headers.authorization || "" };
25
+ seen.push(rec);
26
+ if (req.method === "POST" && url.pathname === "/api/device-login") return send(200, { token: "jwt-abc" });
27
+ if (req.method === "POST" && url.pathname === "/api/auth-key") {
28
+ if (opts.authKeyFail) return send(500, { error: { message: "server boom" } });
29
+ const body = { ok: true, key: "K1", url: "https://app.test/a/K1", expires_at: EXPIRES, ttl_ms: 1800000 };
30
+ if (!opts.noQr) body.qr_data_url = QR;
31
+ return send(200, body);
32
+ }
33
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions") {
34
+ return send(200, {
35
+ ok: true,
36
+ sessions: [
37
+ { id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null },
38
+ { id: "ms_2", label: "Pixel", os: "Android", browser: "Chrome", created_at: 1700000000000, last_seen_at: null, revoked_at: 1700001000000 },
39
+ ],
40
+ });
41
+ }
42
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ms_1/revoke") return send(200, { ok: true });
43
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ghost/revoke") return send(404, { error: { message: "not_found" } });
44
+ send(404, { error: { code: "not_found" } });
45
+ });
46
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
47
+ }
48
+
49
+ /** 以假 relay 为 api_url 装载插件路由(boot),返回 http server base 与收集的 routes。 */
50
+ async function bootRelay(relayPort, cfgExtra = {}) {
51
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-"));
52
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
53
+ phone: "13800000000",
54
+ password: "pw",
55
+ device_id: "dev-aks",
56
+ api_url: `http://127.0.0.1:${relayPort}`,
57
+ ...cfgExtra,
58
+ }));
59
+ const routes = new Map();
60
+ apply({
61
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
62
+ effect(register) { return register(); },
63
+ logger: { info() {}, warn() {} }
64
+ }, { relayDir: tempDir });
65
+ const host = http.createServer((req, res) => {
66
+ const url = new URL(req.url, "http://x");
67
+ const handler = routes.get(url.pathname);
68
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
69
+ });
70
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
71
+ return { host, base: `http://127.0.0.1:${host.address().port}`, tempDir };
72
+ }
73
+
74
+ test("access-key 路由:创建一次性密钥并透传字段(含 qr_data_url),Bearer 已带上", async () => {
75
+ const relay = await startFakeRelay();
76
+ const { host, base, tempDir } = await bootRelay(relay.port);
77
+ try {
78
+ const r = await (await fetch(`${base}/dsh-remote/access-key`)).json();
79
+ assert.equal(r.ok, true);
80
+ assert.equal(r.url, "https://app.test/a/K1");
81
+ assert.equal(r.key, "K1");
82
+ assert.equal(r.expires_at, EXPIRES);
83
+ assert.equal(r.ttl_ms, 1800000);
84
+ assert.equal(r.qr_data_url, QR);
85
+ const up = relay.seen.find((s) => s.path === "/api/auth-key");
86
+ assert.equal(up.method, "POST");
87
+ assert.equal(up.authorization, "Bearer jwt-abc", "应携带 device-login JWT");
88
+ } finally {
89
+ host.close();
90
+ relay.srv.close();
91
+ await rm(tempDir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("access-key 容错:企业端未返回 qr_data_url 时仍成功,qr 字段为 null", async () => {
96
+ const relay = await startFakeRelay({ noQr: true });
97
+ const { host, base, tempDir } = await bootRelay(relay.port);
98
+ try {
99
+ const r = await (await fetch(`${base}/dsh-remote/access-key`)).json();
100
+ assert.equal(r.ok, true);
101
+ assert.equal(r.url, "https://app.test/a/K1");
102
+ assert.equal(r.qr_data_url, null, "qr 缺失不致命(UI 只展示链接/复制/打开)");
103
+ } finally {
104
+ host.close();
105
+ relay.srv.close();
106
+ await rm(tempDir, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ test("mobile-sessions 路由:列表透传,Bearer 已带上", async () => {
111
+ const relay = await startFakeRelay();
112
+ const { host, base, tempDir } = await bootRelay(relay.port);
113
+ try {
114
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions`)).json();
115
+ assert.equal(r.ok, true);
116
+ assert.equal(r.sessions.length, 2);
117
+ assert.equal(r.sessions[0].label, "iPhone 15");
118
+ assert.equal(r.sessions[0].os, "iOS");
119
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions");
120
+ assert.equal(up.method, "POST");
121
+ assert.equal(up.authorization, "Bearer jwt-abc");
122
+ } finally {
123
+ host.close();
124
+ relay.srv.close();
125
+ await rm(tempDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("mobile-sessions/revoke 路由:body {id} 转发到 /api/mobile-sessions/:id/revoke,成功返回 ok", async () => {
130
+ const relay = await startFakeRelay();
131
+ const { host, base, tempDir } = await bootRelay(relay.port);
132
+ try {
133
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
134
+ method: "POST",
135
+ headers: { "content-type": "application/json" },
136
+ body: JSON.stringify({ id: "ms_1" }),
137
+ })).json();
138
+ assert.equal(r.ok, true);
139
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/ms_1/revoke");
140
+ assert.ok(up, "应转发到 /api/mobile-sessions/ms_1/revoke");
141
+ assert.equal(up.authorization, "Bearer jwt-abc");
142
+
143
+ // 缺 id → 400
144
+ const bad = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
145
+ method: "POST",
146
+ headers: { "content-type": "application/json" },
147
+ body: JSON.stringify({}),
148
+ })).json();
149
+ assert.equal(bad.ok, false);
150
+ assert.match(String(bad.error), /id/);
151
+ } finally {
152
+ host.close();
153
+ relay.srv.close();
154
+ await rm(tempDir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("未登录(无账号配置)→ 三条路由统一 401 提示登录,不请求企业端", async () => {
159
+ const relay = await startFakeRelay();
160
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-401-"));
161
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({ api_url: `http://127.0.0.1:${relay.port}` }));
162
+ const routes = new Map();
163
+ apply({
164
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
165
+ effect(register) { return register(); },
166
+ logger: { info() {}, warn() {} }
167
+ }, { relayDir: tempDir });
168
+ const host = http.createServer((req, res) => {
169
+ const url = new URL(req.url, "http://x");
170
+ const handler = routes.get(url.pathname);
171
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
172
+ });
173
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
174
+ try {
175
+ const base = `http://127.0.0.1:${host.address().port}`;
176
+ for (const [method, p] of [["GET", "/dsh-remote/access-key"], ["GET", "/dsh-remote/mobile-sessions"]]) {
177
+ const r = await (await fetch(`${base}${p}`, { method })).json();
178
+ assert.equal(r.ok, false);
179
+ assert.equal((await (await fetch(`${base}${p}`, { method }))).status, 401, `${p} 应返回 401`);
180
+ assert.match(String(r.error), /尚未登录/);
181
+ }
182
+ const rev = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify({ id: "ms_1" }),
186
+ })).json();
187
+ assert.equal(rev.ok, false);
188
+ assert.match(String(rev.error), /尚未登录/);
189
+ assert.ok(!relay.seen.some((s) => s.path.startsWith("/api/auth-key") || s.path.startsWith("/api/mobile-sessions")), "未登录不应请求企业端");
190
+ } finally {
191
+ host.close();
192
+ relay.srv.close();
193
+ await rm(tempDir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test("上游错误透传:auth-key 5xx 时包装 ok:false + 服务端 error.message", async () => {
198
+ const relay = await startFakeRelay({ authKeyFail: true });
199
+ const { host, base, tempDir } = await bootRelay(relay.port);
200
+ try {
201
+ const res = await fetch(`${base}/dsh-remote/access-key`);
202
+ assert.equal(res.status, 500, "应透传上游状态码");
203
+ const r = await res.json();
204
+ assert.equal(r.ok, false);
205
+ assert.match(String(r.error), /server boom/);
206
+ } finally {
207
+ host.close();
208
+ relay.srv.close();
209
+ await rm(tempDir, { recursive: true, force: true });
210
+ }
211
+ });