@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.10

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.
Files changed (31) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +270 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2112 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +1613 -0
  11. package/clients/dsh-remote/test/wechat-tier-source.test.mjs +287 -0
  12. package/clients/dsh-remote/wechat-channel.mjs +2825 -0
  13. package/clients/dsh-remote/wechat-runtime.mjs +1910 -0
  14. package/docs/telemetry.md +31 -2
  15. package/dsh-setup.mjs +192 -43
  16. package/package.json +6 -5
  17. package/packages/dsh-remote-web/lib/client.js +877 -65
  18. package/packages/dsh-remote-web/lib/index.js +511 -14
  19. package/packages/dsh-remote-web/package.json +1 -1
  20. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  21. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +59 -0
  22. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  23. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  24. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  25. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  26. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  27. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  28. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  29. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +1002 -0
  30. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  31. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * 微信通道「档位来源」回归(分层功能的接线段)。
3
+ *
4
+ * 分层本身在 wechat-runtime 里可测;这里守的是**桥接侧喂给它的那个值对不对**:
5
+ * · 自建部署必须是最高档 —— 用户跑的是自己的服务器,不该被 SaaS 的会员分层卡住;
6
+ * · SaaS 走账号的**生效套餐**(free / pro / pro_max,试用期服务端已折算成 pro);
7
+ * · 取不到时必须返回**空串**(让 runtime 沿用上次的档位),绝不能返回 "free" ——
8
+ * 一次网络抖动就把付过钱的用户降级、还提示他去升级,比多给几分钟权限糟糕得多;
9
+ * · App 链接要从账号 API 地址推导出来(免费用户越界时的转化入口)。
10
+ *
11
+ * 这些分支在测试机上跑不到(要真账号 + 真网络),所以把函数从源码抠出来,
12
+ * 注入假 process / resolveToken / fetch,把每条路真的执行一遍。
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+ const BRIDGE_SRC = readFileSync(path.join(HERE, "..", "dsh-bridge.mjs"), "utf8");
22
+ const API_BASE = "https://api.example/relay-api";
23
+
24
+ /** 可控时钟:切片里的 `Date.now()` 是唯一的取时点,替掉它就能确定性验 5 分钟缓存。 */
25
+ function fakeDate(nowFn) {
26
+ return class FakeDate extends Date {
27
+ static now() { return nowFn(); }
28
+ };
29
+ }
30
+
31
+ function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body = { user: { plan: "pro" } }, throws = false, now = null } = {}) {
32
+ // 切片必须**带上 token 缓存那两个声明**(它们在函数之外,漏了就 ReferenceError →
33
+ // 被函数内的 try/catch 吞成空串,测试会假绿)。
34
+ const from = BRIDGE_SRC.indexOf("const WECHAT_TIER_TOKEN_TTL_MS");
35
+ const to = BRIDGE_SRC.indexOf("function wechatAppUrl()");
36
+ assert.ok(from > -1 && to > from, "未能从 dsh-bridge.mjs 切出 resolveWechatTier(锚点已漂移)");
37
+ assert.ok(/async function resolveWechatTier\(\)/.test(BRIDGE_SRC.slice(from, to)),
38
+ "切片必须同时包含 token 缓存声明与 resolveWechatTier");
39
+ const calls = [];
40
+ let tokenCalls = 0;
41
+ // body 允许传函数:这样能在**同一个实例**里改变服务端响应,验"这次没给 user 时快照不被覆盖"。
42
+ const bodyFor = typeof body === "function" ? body : () => body;
43
+ const fakeFetch = async (url, init) => {
44
+ calls.push({ url, init });
45
+ if (throws) throw new Error("network down");
46
+ return { ok: status >= 200 && status < 300, status, json: async () => bodyFor() };
47
+ };
48
+ // 多出口两个"内部状态"的只读探针 —— 否则"token 缓存被清空""快照不因缺 user 被覆盖"
49
+ // 这两条只能用间接行为猜,抓不住回归(它们都在闭包里)。
50
+ const make = new Function("process", "resolveToken", "API_BASE", "fetch", "AbortSignal", "Date",
51
+ `${BRIDGE_SRC.slice(from, to)}
52
+ return {
53
+ fn: resolveWechatTier,
54
+ tokenCache: () => WECHAT_TIER_TOKEN,
55
+ snapshot: () => WECHAT_ACCOUNT_SNAPSHOT
56
+ };`);
57
+ const env = localKey ? { DSH_BRIDGE_LOCAL_KEY: localKey } : {};
58
+ const api = make(
59
+ { env },
60
+ async () => { tokenCalls += 1; return token; },
61
+ API_BASE,
62
+ fakeFetch,
63
+ AbortSignal,
64
+ now ? fakeDate(now) : Date
65
+ );
66
+ return {
67
+ fn: api.fn,
68
+ calls,
69
+ tokenCache: api.tokenCache,
70
+ snapshot: api.snapshot,
71
+ tokenCalls: () => tokenCalls
72
+ };
73
+ }
74
+
75
+ function loadAppUrl(apiBase) {
76
+ const from = BRIDGE_SRC.indexOf("function wechatAppUrl()");
77
+ const to = BRIDGE_SRC.indexOf("let wechatRuntime = null;");
78
+ assert.ok(from > -1 && to > from, "未能从 dsh-bridge.mjs 切出 wechatAppUrl(锚点已漂移)");
79
+ const make = new Function("API_BASE", `${BRIDGE_SRC.slice(from, to)}\nreturn wechatAppUrl;`);
80
+ return make(apiBase);
81
+ }
82
+
83
+ test("档位来源:SaaS 用账号的生效套餐,并带上 Bearer token 打 /api/me", async () => {
84
+ for (const plan of ["free", "pro", "pro_max"]) {
85
+ const { fn, calls } = loadTierResolver({ body: { user: { plan } } });
86
+ assert.equal(await fn(), plan, `必须如实透传服务端的生效套餐 ${plan}`);
87
+ assert.equal(calls.length, 1, "只应请求一次");
88
+ assert.equal(calls[0].url, `${API_BASE}/api/me`, "应打账号 API 的 /api/me");
89
+ assert.equal(calls[0].init.headers.authorization, "Bearer tok-1", "必须带 token");
90
+ assert.ok(calls[0].init.signal, "必须有超时信号(不能把档位查询挂死)");
91
+ }
92
+ });
93
+
94
+ test("★档位来源:自建部署直接给最高档,且**不去打账号 API**", async () => {
95
+ // 自建用户跑的是自己的服务器,不存在"会员"这回事。
96
+ // 而且必须**一次网络请求都不发** —— 自建环境常常连不上(也不该连)账号 API。
97
+ const { fn, calls } = loadTierResolver({ localKey: "local-key-abc" });
98
+ assert.equal(await fn(), "pro_max", "自建部署必须是最高档");
99
+ assert.equal(calls.length, 0, "★自建模式不得请求账号 API");
100
+ });
101
+
102
+ test("★档位来源:取不到时返回空串(让上层沿用旧值),绝不返回 free 把付费用户降级", async () => {
103
+ // 空串 = "这次没取到",runtime 据此保留上次的档位;返回 "free" 则会把人降级。
104
+ const http = loadTierResolver({ status: 503, body: {} });
105
+ assert.equal(await http.fn(), "", "HTTP 失败 → 空串");
106
+ const boom = loadTierResolver({ throws: true });
107
+ assert.equal(await boom.fn(), "", "网络异常必须被吞掉并返回空串(档位不是关键路径,不能拖垮 bridge)");
108
+ const noToken = loadTierResolver({ token: "" });
109
+ assert.equal(await noToken.fn(), "", "拿不到 token → 空串");
110
+ assert.equal(noToken.calls.length, 0, "没 token 就不该白跑一次请求");
111
+ const weird = loadTierResolver({ body: { user: {} } });
112
+ assert.equal(await weird.fn(), "", "服务端没给 plan → 空串(不是猜一个 free)");
113
+ });
114
+
115
+ test("★档位来源:token 必须自己缓存(否则免费用户连点几下就把自己打到登录限流)", async () => {
116
+ // `resolveToken()` 在账号模式下**每调一次就重新 device-login 一次**,而 /api/device-login
117
+ // 是按**出口 IP** 限流的(15 分钟 5 次)。档位会被反复查询(定时校准 + 免费用户每次被拒都重查),
118
+ // 不缓存就是拿用户的登录额度去换一个只读字段。
119
+ const { fn, calls, tokenCalls } = loadTierResolver({ body: { user: { plan: "free" } } });
120
+ await fn();
121
+ await fn();
122
+ await fn();
123
+ assert.equal(tokenCalls(), 1, "★多次查档位只应登录一次");
124
+ assert.equal(calls.length, 3, "但 /api/me 每次都要查(档位本身要新鲜)");
125
+ });
126
+
127
+ test("★档位来源:token 失效(401/403)时丢掉缓存,下次重新登录而不是一直用坏 token", async () => {
128
+ const bad = loadTierResolver({ status: 401, body: {} });
129
+ assert.equal(await bad.fn(), "", "401 → 空串(沿用上层旧档位,不降级)");
130
+ assert.equal(await bad.fn(), "", "仍然空串");
131
+ assert.equal(bad.tokenCalls(), 2, "★401 之后必须重新登录一次(缓存已失效,不能一直拿坏 token 打)");
132
+ });
133
+
134
+ test("★档位来源:启动时必须复用桥接刚拿到的 JWT(否则每次启动多花一份登录额度)", () => {
135
+ // 真机日志实测:同一个桥接启动里出现两条并发的 device-login —— 一条是隧道登录,
136
+ // 另一条是档位校准。而 device-login 按出口 IP 限流,每次启动多花一份额度没有道理。
137
+ const i = BRIDGE_SRC.indexOf("async function runTunnel()");
138
+ assert.ok(i > -1, "应能找到 runTunnel");
139
+ const body = BRIDGE_SRC.slice(i, BRIDGE_SRC.indexOf("async function main()"));
140
+ assert.ok(body.length > 0, "切片边界应正确");
141
+ const seedAt = body.indexOf("WECHAT_TIER_TOKEN = { value: token");
142
+ const startAt = body.indexOf("startWeChat()");
143
+ assert.ok(seedAt > -1, "runTunnel 必须把刚拿到的 token 喂给档位缓存");
144
+ assert.ok(startAt > -1, "runTunnel 应调用 startWeChat()");
145
+ assert.ok(seedAt < startAt, "★必须在 startWeChat() **之前**喂,否则档位校准仍会再登录一次");
146
+ });
147
+
148
+ test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL 同口径", () => {
149
+ assert.equal(loadAppUrl(API_BASE)(), "https://api.example/app/");
150
+ assert.equal(loadAppUrl("https://n.risegao.cn:13443/relay-api")(), "https://n.risegao.cn:13443/app/",
151
+ "生产地址必须推导出真实可点的 App 入口");
152
+ assert.equal(loadAppUrl("https://api.example/relay-api/")(), "https://api.example/app/",
153
+ "结尾斜杠也要正确吃掉");
154
+ });
155
+
156
+ // ── 权益包(服务端 /api/me 新增的 entitlements 字段,契约 §5/§6.1) ──────────
157
+
158
+ const FREE_ENT = Object.freeze({
159
+ plan: "free",
160
+ caps: ["notify", "approve", "status", "stop", "assign.continue"],
161
+ limits: { messages_per_month: 20 },
162
+ rev: "a1b2c3d4",
163
+ source: "configured"
164
+ });
165
+
166
+ test("★权益包:/api/me 带 entitlements 时返回对象,caps/limits/rev 原样透传", async () => {
167
+ // 为什么必须**原样**:服务端是权限的唯一真相源(契约 §0)。桥接侧任何"本地过滤/补齐"
168
+ // 都会让后台配置失效(红线 §8.1「话术与权限一致」直接建立在"下发什么就是什么"上)。
169
+ const { fn } = loadTierResolver({ body: { user: { plan: "free" }, entitlements: FREE_ENT } });
170
+ const got = await fn();
171
+ assert.equal(typeof got, "object", "有权益包就必须返回对象(runtime 才能以它为准判定)");
172
+ assert.equal(got.plan, "free");
173
+ assert.deepEqual(got.caps, FREE_ENT.caps);
174
+ assert.equal(got.caps, FREE_ENT.caps, "★caps 必须原样透传(连数组都不重建,杜绝二次加工)");
175
+ assert.deepEqual(got.limits, { messages_per_month: 20 });
176
+ assert.equal(got.limits, FREE_ENT.limits, "★limits 同样原样透传");
177
+ assert.equal(got.rev, "a1b2c3d4", "rev 要带上(客户端据此判断包变没变)");
178
+ });
179
+
180
+ test("★权益包:没有 entitlements 时保持今天的字符串契约(新旧服务端都能跑)", async () => {
181
+ // 灰度期:服务端没发版 → 只给 {user}。这时绝不能返回对象(缺 caps 会让 runtime 判不出档位),
182
+ // 必须退回字符串,让 runtime 按内置表解析 —— 与改动前**逐字一致**的行为。
183
+ for (const plan of ["free", "pro", "pro_max"]) {
184
+ const { fn } = loadTierResolver({ body: { user: { plan } } });
185
+ const got = await fn();
186
+ assert.equal(typeof got, "string", `旧服务端必须走字符串契约(plan=${plan})`);
187
+ assert.equal(got, plan);
188
+ }
189
+ });
190
+
191
+ test("★权益包:caps 为**数组**(含空数组)就走对象契约;不是数组/缺字段才退回字符串", async () => {
192
+ // 2026-09-23 修正:原先这里要求"caps 必须非空",把 `caps: []` 也挡在对象契约之外 ——
193
+ // 那是个 **fail-open**:后台「明确清空某档」(契约 §4.1)下发 `caps: []`,
194
+ // 桥接当"没给"退回字符串 → runtime 沿用上次那份包(或回退**内置表**)→
195
+ // 本该"什么都不能做"的档位拿回一整套能力,比后台意图**更多**权限。
196
+ // 正确语义:数组 = 权威(空数组就是"空");不是数组/缺字段 = 这次没拿到 → 沿用上次。
197
+ const bad = [
198
+ { entitlements: { plan: "free", caps: null, limits: { messages_per_month: 20 }, rev: "r" } },
199
+ { entitlements: { plan: "free", limits: { messages_per_month: 20 }, rev: "r" } },
200
+ { entitlements: { plan: "free", caps: "assign.new", rev: "r" } } // 字符串不是数组
201
+ ];
202
+ for (const body of bad) {
203
+ const { fn } = loadTierResolver({ body: { user: { plan: "free" }, ...body } });
204
+ assert.equal(await fn(), "free", `坏权益包必须退回字符串契约:${JSON.stringify(body.entitlements)}`);
205
+ }
206
+
207
+ // ★ 空数组 = 权威的"这一档什么都不能做",必须原样作为对象返回
208
+ const { fn } = loadTierResolver({
209
+ body: { user: { plan: "pro" }, entitlements: { plan: "pro", caps: [], limits: { messages_per_month: 0 }, rev: "r9" } }
210
+ });
211
+ const got = await fn();
212
+ assert.equal(typeof got, "object", "★caps: [] 必须是有效的权益包,不能退回字符串(否则清空会静默失效)");
213
+ assert.deepEqual([...got.caps], [], "空数组要原样透传,不能被换成别的");
214
+ assert.equal(got.plan, "pro");
215
+ assert.equal(got.rev, "r9");
216
+ });
217
+
218
+ test("★权益包:limits 缺失时给 null 而不是 {}(补 {} 会被 runtime 读成「不限」=放权)", async () => {
219
+ // runtime 里 `limits` 只要是对象就生效,而 `{}` 的 messages_per_month 读成 0 = 不限。
220
+ // 于是"服务端这次没给 limits"会静默把免费用户变成无限额度 —— 缺字段必须 fail-closed。
221
+ const { fn } = loadTierResolver({
222
+ body: { user: { plan: "free" }, entitlements: { plan: "free", caps: ["notify"], rev: "r1" } }
223
+ });
224
+ const got = await fn();
225
+ assert.equal(typeof got, "object");
226
+ assert.equal(got.limits, null, "★缺 limits 必须是 null(runtime 据此沿用上次 / 回退内置表)");
227
+ assert.equal(got.rev, "r1");
228
+ });
229
+
230
+ test("★权益包:401/403 丢弃 token 缓存、返回空串(沿用旧值,绝不降级)", async () => {
231
+ // 「空串 = 这次没取到」是整条不降级链路的起点:retrurn "free" 会把付过钱的用户降级,
232
+ // 而"缓存没清"会让下一次查询继续拿坏 token 打(用户永远恢复不了)。两条都要证明。
233
+ for (const status of [401, 403]) {
234
+ const r = loadTierResolver({ status, body: { user: { plan: "free" }, entitlements: FREE_ENT } });
235
+ const got = await r.fn();
236
+ assert.equal(got, "", `${status} 必须返回空串(上层沿用缓存,不降级)`);
237
+ assert.equal(r.tokenCache().value, "", `★${status} 之后 token 缓存必须被清空`);
238
+ assert.equal(r.tokenCache().at, 0, "缓存时间戳也要归零(否则被当成「刚取过」继续用)");
239
+ await r.fn();
240
+ assert.equal(r.tokenCalls(), 2, "★清空后必须重新登录,而不是一直拿坏 token 打");
241
+ }
242
+ });
243
+
244
+ test("★权益包:token 的 5 分钟缓存原样保留(device-login 是按出口 IP 限流的)", () => {
245
+ // /api/device-login 限流是**按出口 IP**的(15 分钟 5 次),连累的不只是这个用户。
246
+ // 所以档位查询自己缓存 token 的逻辑一个字都不能动 —— 这里把 TTL 与行为都锁死。
247
+ assert.match(BRIDGE_SRC, /const WECHAT_TIER_TOKEN_TTL_MS = 5 \* 60_000;/,
248
+ "★TTL 必须还是 5 分钟(改动它就是改限流预算)");
249
+ });
250
+
251
+ test("★权益包:TTL 内不重复登录,超过 TTL 才重新登录", async () => {
252
+ let t = 1_700_000_000_000;
253
+ const r = loadTierResolver({ body: { user: { plan: "free" }, entitlements: FREE_ENT }, now: () => t });
254
+ await r.fn();
255
+ assert.equal(r.tokenCalls(), 1);
256
+
257
+ t += 4 * 60_000; // TTL 内
258
+ await r.fn();
259
+ assert.equal(r.tokenCalls(), 1, "TTL 内不得重新登录(每 10 分钟一次的档位校准必须被缓存挡住)");
260
+
261
+ t += 2 * 60_000; // 累计 6 分钟 > 5 分钟
262
+ await r.fn();
263
+ assert.equal(r.tokenCalls(), 2, "超过 TTL 才重新登录");
264
+ });
265
+
266
+ test("★权益包:账号快照只在真拿到 user 时覆盖(到期提醒的数据源)", async () => {
267
+ // 到期提醒复用这次 /api/me 的结果(不额外打网络)。所以"这次没给 user"时**必须保持上次** ——
268
+ // 否则一次抖动就会让到期提醒凭空消失(或拿 null 去算到期日)。
269
+ let body = { user: { plan: "pro", plan_ends_at: 1_800_000_000_000 }, entitlements: { ...FREE_ENT, plan: "pro" } };
270
+ const r = loadTierResolver({ body: () => body });
271
+
272
+ await r.fn();
273
+ assert.equal(r.snapshot().plan_ends_at, 1_800_000_000_000, "拿到 user 时要记快照");
274
+
275
+ // 服务端这次只发了权益包、没有 user → 快照必须保持上次的值,而权益包照常返回
276
+ body = { entitlements: FREE_ENT };
277
+ const got = await r.fn();
278
+ assert.equal(typeof got, "object", "没有 user 也要照常返回权益包(两者互不依赖)");
279
+ assert.equal(r.snapshot().plan_ends_at, 1_800_000_000_000,
280
+ "★没有 user 时不得覆盖/清空快照(一次抖动不该让到期提醒消失)");
281
+
282
+ // 从没拿到过 user → 快照就是 null,不许凭空造一个
283
+ const fresh = loadTierResolver({ body: { entitlements: FREE_ENT } });
284
+ await fresh.fn();
285
+ assert.equal(fresh.snapshot(), null, "没取到过 user 就该是 null(上层据此什么都不发)");
286
+ });
287
+