mn-ws 0.1.0 → 0.2.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.
Files changed (178) hide show
  1. package/assets/groups.json +10 -0
  2. package/assets/mn-ws/SKILL.md +55 -0
  3. package/assets/mn-ws/hooks.json +14 -0
  4. package/assets/mn-ws/install/inject-root.mjs +26 -0
  5. package/bin/ws.mjs +13 -0
  6. package/dist/core/auth.d.ts +194 -0
  7. package/dist/core/auth.d.ts.map +1 -0
  8. package/dist/core/auth.js +331 -0
  9. package/dist/core/auth.js.map +1 -0
  10. package/dist/core/brand.d.ts +11 -0
  11. package/dist/core/brand.d.ts.map +1 -0
  12. package/dist/core/brand.js +7 -0
  13. package/dist/core/brand.js.map +1 -0
  14. package/dist/core/bridge.d.ts +106 -0
  15. package/dist/core/bridge.d.ts.map +1 -0
  16. package/dist/core/bridge.js +308 -0
  17. package/dist/core/bridge.js.map +1 -0
  18. package/dist/core/config.d.ts +174 -0
  19. package/dist/core/config.d.ts.map +1 -0
  20. package/dist/core/config.js +417 -0
  21. package/dist/core/config.js.map +1 -0
  22. package/dist/core/controller.d.ts +64 -0
  23. package/dist/core/controller.d.ts.map +1 -0
  24. package/dist/core/controller.js +100 -0
  25. package/dist/core/controller.js.map +1 -0
  26. package/dist/core/errors/index.d.ts +30 -0
  27. package/dist/core/errors/index.d.ts.map +1 -0
  28. package/dist/core/errors/index.js +88 -0
  29. package/dist/core/errors/index.js.map +1 -0
  30. package/dist/core/open.d.ts +3 -0
  31. package/dist/core/open.d.ts.map +1 -0
  32. package/dist/core/open.js +27 -0
  33. package/dist/core/open.js.map +1 -0
  34. package/dist/core/paths.d.ts +15 -0
  35. package/dist/core/paths.d.ts.map +1 -0
  36. package/dist/core/paths.js +50 -0
  37. package/dist/core/paths.js.map +1 -0
  38. package/dist/core/serve-port.d.ts +73 -0
  39. package/dist/core/serve-port.d.ts.map +1 -0
  40. package/dist/core/serve-port.js +134 -0
  41. package/dist/core/serve-port.js.map +1 -0
  42. package/dist/core/session.d.ts +78 -0
  43. package/dist/core/session.d.ts.map +1 -0
  44. package/dist/core/session.js +124 -0
  45. package/dist/core/session.js.map +1 -0
  46. package/dist/core/skills/bus.d.ts +29 -0
  47. package/dist/core/skills/bus.d.ts.map +1 -0
  48. package/dist/core/skills/bus.js +114 -0
  49. package/dist/core/skills/bus.js.map +1 -0
  50. package/dist/core/skills/get.d.ts +17 -0
  51. package/dist/core/skills/get.d.ts.map +1 -0
  52. package/dist/core/skills/get.js +87 -0
  53. package/dist/core/skills/get.js.map +1 -0
  54. package/dist/core/skills/handlers.d.ts +22 -0
  55. package/dist/core/skills/handlers.d.ts.map +1 -0
  56. package/dist/core/skills/handlers.js +86 -0
  57. package/dist/core/skills/handlers.js.map +1 -0
  58. package/dist/core/skills/index.d.ts +47 -0
  59. package/dist/core/skills/index.d.ts.map +1 -0
  60. package/dist/core/skills/index.js +268 -0
  61. package/dist/core/skills/index.js.map +1 -0
  62. package/dist/core/skills/install.d.ts +18 -0
  63. package/dist/core/skills/install.d.ts.map +1 -0
  64. package/dist/core/skills/install.js +123 -0
  65. package/dist/core/skills/install.js.map +1 -0
  66. package/dist/core/skills/text-util.d.ts +2 -0
  67. package/dist/core/skills/text-util.d.ts.map +1 -0
  68. package/dist/core/skills/text-util.js +14 -0
  69. package/dist/core/skills/text-util.js.map +1 -0
  70. package/dist/core/store.d.ts +29 -0
  71. package/dist/core/store.d.ts.map +1 -0
  72. package/dist/core/store.js +100 -0
  73. package/dist/core/store.js.map +1 -0
  74. package/dist/core/views.d.ts +11 -0
  75. package/dist/core/views.d.ts.map +1 -0
  76. package/dist/core/views.js +20 -0
  77. package/dist/core/views.js.map +1 -0
  78. package/dist/index.d.ts +6 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +8 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/modules/auth/index.d.ts +73 -0
  83. package/dist/modules/auth/index.d.ts.map +1 -0
  84. package/dist/modules/auth/index.js +64 -0
  85. package/dist/modules/auth/index.js.map +1 -0
  86. package/dist/modules/auth/service.d.ts +98 -0
  87. package/dist/modules/auth/service.d.ts.map +1 -0
  88. package/dist/modules/auth/service.js +200 -0
  89. package/dist/modules/auth/service.js.map +1 -0
  90. package/dist/modules/bridge/index.d.ts +73 -0
  91. package/dist/modules/bridge/index.d.ts.map +1 -0
  92. package/dist/modules/bridge/index.js +89 -0
  93. package/dist/modules/bridge/index.js.map +1 -0
  94. package/dist/modules/bridge/service.d.ts +75 -0
  95. package/dist/modules/bridge/service.d.ts.map +1 -0
  96. package/dist/modules/bridge/service.js +134 -0
  97. package/dist/modules/bridge/service.js.map +1 -0
  98. package/dist/modules/home/index.d.ts +95 -0
  99. package/dist/modules/home/index.d.ts.map +1 -0
  100. package/dist/modules/home/index.js +123 -0
  101. package/dist/modules/home/index.js.map +1 -0
  102. package/dist/modules/home/service.d.ts +48 -0
  103. package/dist/modules/home/service.d.ts.map +1 -0
  104. package/dist/modules/home/service.js +195 -0
  105. package/dist/modules/home/service.js.map +1 -0
  106. package/dist/modules/skill/index.d.ts +78 -0
  107. package/dist/modules/skill/index.d.ts.map +1 -0
  108. package/dist/modules/skill/index.js +132 -0
  109. package/dist/modules/skill/index.js.map +1 -0
  110. package/dist/runtime/api.d.ts +24 -0
  111. package/dist/runtime/api.d.ts.map +1 -0
  112. package/dist/runtime/api.js +140 -0
  113. package/dist/runtime/api.js.map +1 -0
  114. package/dist/runtime/cli.d.ts +23 -0
  115. package/dist/runtime/cli.d.ts.map +1 -0
  116. package/dist/runtime/cli.js +410 -0
  117. package/dist/runtime/cli.js.map +1 -0
  118. package/dist/runtime/help.d.ts +29 -0
  119. package/dist/runtime/help.d.ts.map +1 -0
  120. package/dist/runtime/help.js +89 -0
  121. package/dist/runtime/help.js.map +1 -0
  122. package/dist/runtime/registry.d.ts +318 -0
  123. package/dist/runtime/registry.d.ts.map +1 -0
  124. package/dist/runtime/registry.js +75 -0
  125. package/dist/runtime/registry.js.map +1 -0
  126. package/dist/runtime/server.d.ts +10 -0
  127. package/dist/runtime/server.d.ts.map +1 -0
  128. package/dist/runtime/server.js +127 -0
  129. package/dist/runtime/server.js.map +1 -0
  130. package/dist/runtime/spec.d.ts +32 -0
  131. package/dist/runtime/spec.d.ts.map +1 -0
  132. package/dist/runtime/spec.js +180 -0
  133. package/dist/runtime/spec.js.map +1 -0
  134. package/dist/runtime/types/action.d.ts +101 -0
  135. package/dist/runtime/types/action.d.ts.map +1 -0
  136. package/dist/runtime/types/action.js +19 -0
  137. package/dist/runtime/types/action.js.map +1 -0
  138. package/dist/runtime/types/client.d.ts +39 -0
  139. package/dist/runtime/types/client.d.ts.map +1 -0
  140. package/dist/runtime/types/client.js +2 -0
  141. package/dist/runtime/types/client.js.map +1 -0
  142. package/dist/runtime/types/http.d.ts +22 -0
  143. package/dist/runtime/types/http.d.ts.map +1 -0
  144. package/dist/runtime/types/http.js +6 -0
  145. package/dist/runtime/types/http.js.map +1 -0
  146. package/dist/runtime/types/module.d.ts +72 -0
  147. package/dist/runtime/types/module.d.ts.map +1 -0
  148. package/dist/runtime/types/module.js +15 -0
  149. package/dist/runtime/types/module.js.map +1 -0
  150. package/dist/web/public/assets/CliHints-BOxU-Duc.js +1 -0
  151. package/dist/web/public/assets/index-BTRKdMIW.js +50 -0
  152. package/dist/web/public/assets/index-BjGe8ggI.css +1 -0
  153. package/dist/web/public/assets/view-CE4tnCuy.js +1 -0
  154. package/dist/web/public/assets/view-CEQ2uPxE.js +1 -0
  155. package/dist/web/public/favicon-16.png +0 -0
  156. package/dist/web/public/favicon-32.png +0 -0
  157. package/dist/web/public/favicon-48.png +0 -0
  158. package/dist/web/public/favicon.ico +0 -0
  159. package/dist/web/public/index.html +19 -0
  160. package/dist/web/public/logo-rounded.png +0 -0
  161. package/dist/web/public/logo-rounded.svg +6 -0
  162. package/dist/web/public/logo.png +0 -0
  163. package/dist/web/public/logo.svg +6 -0
  164. package/package.json +52 -31
  165. package/README.md +0 -251
  166. package/bin/index.js +0 -714
  167. package/client/bridge-client.js +0 -160
  168. package/client/web.js +0 -234
  169. package/client/ws-client.js +0 -191
  170. package/lib/apps.js +0 -237
  171. package/lib/auth.js +0 -319
  172. package/lib/config.js +0 -534
  173. package/lib/index.js +0 -15
  174. package/lib/logger.js +0 -63
  175. package/lib/protocol.js +0 -208
  176. package/lib/store.js +0 -66
  177. package/server/auth.js +0 -274
  178. package/server/server.js +0 -752
package/lib/apps.js DELETED
@@ -1,237 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * 可桥接应用(app)表与探测。
5
- *
6
- * ## 为什么要有「应用标识」
7
- *
8
- * 一台机器上跑着十几个 nx 服务(nx-kv / nx-rh / nx-sk …),每个占一个端口。
9
- * 桥接要替它们**共用同一条对外的 HTTP 入口**,就必须能区分「这个请求该去
10
- * 哪个本地端口」——那就是**应用标识(app)**。
11
- *
12
- * ## 标识怎么带
13
- *
14
- * 两种都支持,**路径前缀优先**:
15
- * ① `POST /nx-kv/api/todo/add` → app=nx-kv, path=/api/todo/add
16
- * ② `POST /api/todo/add` + 头 `X-Nx-App: nx-kv` → app=nx-kv, path=/api/todo/add
17
- *
18
- * 路径优先的理由:客户端零改造(curl 直接打)、自定义头不会被中间反代剥掉、
19
- * 且一个请求只可能对应一个 app(没有「路径说 A、头说 B」的歧义)。
20
- *
21
- * 两者都缺 → app_required;两者冲突 → app_conflict。
22
- * **冲突时报错而不是猜**:猜错等于把请求打到别人的服务上。
23
- */
24
-
25
- const net = require('node:net');
26
-
27
- /** 请求头:应用标识的备用载体。 */
28
- const APP_HEADER = 'x-nx-app';
29
-
30
- /**
31
- * 已知 nx 服务(2026-11-12 实测各仓 core/paths.js 的 DEFAULT_PORT)。
32
- *
33
- * **这不是白名单** —— 它只是「本机可能有什么」的提示与兜底默认值。
34
- * 真正能不能桥,由 client half 的配置(--apps / 配置文件)决定:
35
- * 端口探测只用来显示状态,**绝不因为「扫到端口在监听」就自动开桥**。
36
- */
37
- const KNOWN_APPS = {
38
- 'nx-rh': { title: '仓库与 Skill 中枢', port: 7800 },
39
- 'nx-rp': { title: '外部上下文', port: 7820 },
40
- 'nx-sk': { title: '个人资源与密钥', port: 7866 },
41
- 'nx-ft': { title: '前端资源管理', port: 7869 },
42
- 'nx-kv': { title: 'KV 清单中枢', port: 7877 },
43
- 'nx-nx': { title: '项目生成器', port: 7880 },
44
- 'nx-kn': { title: 'Obsidian 知识库', port: 7881 },
45
- 'nx-ditto': { title: 'ditto', port: 7883 },
46
- 'nx-pg': { title: '画布工作台', port: 7888 },
47
- 'nx-pn': { title: 'pn', port: 4561 },
48
- };
49
-
50
- /** app 名的合法形态:小写字母开头,只含小写字母/数字/连字符。 */
51
- const APP_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
52
-
53
- /**
54
- * 保留路径段:这些第一段**不可能**是应用标识。
55
- *
56
- * 没有这张表时,'/api/todo/list' 的第一段 'api' 会被当成 app 名 ——
57
- * 合法字符、全小写、还长得像个应用 —— 于是请求被路由到不存在的 app。
58
- * 这张表把服务自身的路径前缀排除掉。
59
- */
60
- const RESERVED_PATH_SEGMENTS = new Set([
61
- 'api', 'auth', 'health', 'stats', 'clients', 'push', 'ws',
62
- 'files', 'assets', 'static', 'docs', 'favicon.ico',
63
- ]);
64
-
65
- function isValidAppName(name) {
66
- return typeof name === 'string' && APP_NAME_RE.test(name);
67
- }
68
-
69
- /**
70
- * 解析一个 app 的本地基址。
71
- *
72
- * 优先用配置里给的 base;没给就用已知端口兜底。
73
- * 返回 null 表示这个 app 没有可用的本地端口。
74
- */
75
- function baseOf(app, overrides) {
76
- const over = overrides && overrides[app];
77
- if (over && over.base) return over.base;
78
- const known = KNOWN_APPS[app];
79
- if (!known) return null;
80
- return `http://127.0.0.1:${known.port}`;
81
- }
82
-
83
- /**
84
- * 探测本机某个端口是否在监听。
85
- *
86
- * 用 TCP connect 而不是 HTTP 请求:桥接关心的是「这个服务在不在」,
87
- * 不是「它返回什么」。连不上就是不在,不多问。
88
- */
89
- function probePort(host, port, timeoutMs = 400) {
90
- return new Promise((resolve) => {
91
- const socket = new net.Socket();
92
- let settled = false;
93
- const done = (ok) => {
94
- if (settled) return;
95
- settled = true;
96
- try { socket.destroy(); } catch { /* ignore */ }
97
- resolve(ok);
98
- };
99
- socket.setTimeout(timeoutMs);
100
- socket.once('connect', () => done(true));
101
- socket.once('timeout', () => done(false));
102
- socket.once('error', () => done(false));
103
- socket.connect(port, host);
104
- });
105
- }
106
-
107
- /**
108
- * 扫描本机各已知 app 的端口,返回可桥接清单(含存活状态)。
109
- *
110
- * 注意返回的是「候选清单」——**能不能桥由配置决定**,这里只提供事实。
111
- */
112
- async function scanApps({ host = '127.0.0.1', apps = null, overrides = null } = {}) {
113
- const names = apps && apps.length ? apps : Object.keys(KNOWN_APPS);
114
- const out = [];
115
- for (const app of names) {
116
- const base = baseOf(app, overrides);
117
- const known = KNOWN_APPS[app];
118
- const port = known ? known.port : (base ? new URL(base).port : null);
119
- const alive = port ? await probePort(host, Number(port)) : false;
120
- out.push({
121
- app,
122
- title: known ? known.title : app,
123
- base,
124
- port: port ? Number(port) : null,
125
- alive,
126
- });
127
- }
128
- return out;
129
- }
130
-
131
- /**
132
- * 检查某个端口能不能绑(= 现在没人占)。
133
- *
134
- * 为什么要预检:**静默顺延端口是这里最坏的处理**。server 顺延到 7778,
135
- * 客户端还按 7777 去连 —— 连不上还是连不上,而用户看到的现象是
136
- * 「服务端说起来了、客户端说连不上」,两边对不上账。宁可**明确报错退出**。
137
- */
138
- function isPortFree(port, host = '127.0.0.1') {
139
- return new Promise((resolve) => {
140
- const server = net.createServer();
141
- let settled = false;
142
- const done = (free, err) => {
143
- if (settled) return;
144
- settled = true;
145
- try { server.close(); } catch { /* ignore */ }
146
- resolve({ free, error: err || null });
147
- };
148
- server.once('error', (e) => done(false, e));
149
- server.once('listening', () => done(true, null));
150
- try {
151
- server.listen(port, host);
152
- } catch (e) {
153
- done(false, e);
154
- }
155
- });
156
- }
157
-
158
- /**
159
- * 描述一个可桥接应用的元信息。
160
- *
161
- * client half 注册时把这份元信息一并报给 server half,server 把它汇总成
162
- * **元信息目录**(catalog)—— 远端客户端登录后先拉目录,看到「哪些能路由、
163
- * 各自是什么」,再决定调哪个。
164
- *
165
- * @param {string} app
166
- * @param {string} base 本地基址,如 http://127.0.0.1:7877
167
- */
168
- function describeApp(app, base) {
169
- const known = KNOWN_APPS[app] || null;
170
- let port = null;
171
- try {
172
- port = base ? Number(new URL(base).port || '') || null : null;
173
- } catch {
174
- port = null;
175
- }
176
- return {
177
- app,
178
- base,
179
- port: port || (known ? known.port : null),
180
- title: known ? known.title : app,
181
- known: !!known,
182
- };
183
- }
184
-
185
- /**
186
- * 从请求里解析出应用标识与剩余路径。
187
- *
188
- * @param {string} pathname 请求路径(不含 query)
189
- * @param {object} headers 规范化后的小写头对象
190
- * @returns {{app: string, path: string} | {error: string}}
191
- */
192
- function resolveApp(pathname, headers) {
193
- const segs = String(pathname || '/').split('/').filter(Boolean);
194
-
195
- // ① 路径前缀:第一段就是已知/声明过的 app 名(且不是服务自身的保留段)
196
- let pathApp = null;
197
- if (segs.length
198
- && isValidAppName(segs[0])
199
- && !RESERVED_PATH_SEGMENTS.has(segs[0])) {
200
- pathApp = segs[0];
201
- }
202
-
203
- // ② 请求头
204
- let headerApp = null;
205
- const hv = headers && headers[APP_HEADER];
206
- if (hv && hv.length) {
207
- const v = String(hv[0]).trim();
208
- if (v) {
209
- if (!isValidAppName(v)) return { error: 'invalid_app_name' };
210
- headerApp = v;
211
- }
212
- }
213
-
214
- if (pathApp && headerApp && pathApp !== headerApp) {
215
- return { error: 'app_conflict' };
216
- }
217
- const app = pathApp || headerApp;
218
- if (!app) return { error: 'app_required' };
219
-
220
- const rest = pathApp ? segs.slice(1) : segs;
221
- const path = '/' + rest.join('/');
222
- return { app, path: path === '/' ? '/' : path.replace(/\/+$/, '') };
223
- }
224
-
225
- module.exports = {
226
- APP_HEADER,
227
- RESERVED_PATH_SEGMENTS,
228
- KNOWN_APPS,
229
- APP_NAME_RE,
230
- isValidAppName,
231
- baseOf,
232
- describeApp,
233
- probePort,
234
- isPortFree,
235
- scanApps,
236
- resolveApp,
237
- };
package/lib/auth.js DELETED
@@ -1,319 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * 统一登录:`mn-ws auth login`。
5
- *
6
- * 流程与 mn-rt 的 `auth login` **完全同款**(全程零复制粘贴):
7
- * ① GET <server>/auth/oidc/config 问服务端"认证中心在哪、用哪个 client"
8
- * ② 本机 127.0.0.1:<随机端口> 起一次性监听,生成 PKCE,打开系统浏览器
9
- * ③ 用户在认证中心登录 → 302 回本机监听 → 拿到 code
10
- * ④ 用 code + verifier 直接在**认证中心**换 access_token(公共客户端,无 secret)
11
- * ⑤ 带 Bearer 调 <server>/auth/verify → 换回 mn-ws 自己的设备令牌
12
- * ⑥ 落盘到 ~/.mn-ws/config.json,之后 client / server 静默携带
13
- *
14
- * ── 为什么第 ④ 步由 CLI 自己换、而不是把 code 交给服务端换 ──────────────
15
- * 交给服务端换需要服务端自己发起 authorize 并持有 PKCE verifier —— 那就多一份
16
- * RP 会话(cookie/存储)要实现。CLI 自己换 token 则只需登记回环通配
17
- * (127.0.0.1 端口任意、路径固定 /callback)—— RFC 8252 给原生客户端的标准做法。
18
- *
19
- * ── 为什么不存 refresh_token ────────────────────────────────────────────
20
- * 第 ④ 步换到的 refresh_token 直接丢弃。CLI 真正需要长期持有的是第 ⑤ 步
21
- * 服务端签发的**设备令牌**——它可被服务端吊销,而 refresh_token 一旦落到
22
- * 每台机器的磁盘上,就是把「谁拿到谁可用 7 天」的凭据复制到 N 台机器。
23
- */
24
-
25
- const http = require('http');
26
- const crypto = require('crypto');
27
- const { execFile } = require('child_process');
28
- const { URL } = require('url');
29
-
30
- /** CLI 侧回环回调地址。端口 0 让内核分配,再回填进 authorize 请求。 */
31
- const LOOPBACK_PATH = '/callback';
32
- /** 等用户完成登录的时间上限(5 分钟)。 */
33
- const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
34
-
35
- function b64url(buf) {
36
- return Buffer.from(buf).toString('base64')
37
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
- }
39
-
40
- /**
41
- * 生成 PKCE 与一次性参数。
42
- *
43
- * 注意 state 只有**一个**值:authorizeParams 里的那个就是真正发出去的,
44
- * 也是回调回来后要比对的。曾经同时返回两个独立随机值,导致每次登录都必然
45
- * 报 state 不一致 —— 这里只留一份。
46
- */
47
- function createAuthRequest(redirectUri) {
48
- const verifier = b64url(crypto.randomBytes(32));
49
- const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
50
- const state = b64url(crypto.randomBytes(16));
51
- return {
52
- verifier,
53
- state,
54
- nonce: b64url(crypto.randomBytes(16)),
55
- authorizeParams: {
56
- response_type: 'code',
57
- redirect_uri: redirectUri,
58
- code_challenge: challenge,
59
- code_challenge_method: 'S256',
60
- state,
61
- },
62
- };
63
- }
64
-
65
- /**
66
- * 起一次性回环监听,等浏览器把 code 送回来。
67
- * 只绑 127.0.0.1:绑 0.0.0.0 会让同网段任何人都能来抢这个 code。
68
- */
69
- function openLoopback() {
70
- let server;
71
- let resolveFn;
72
- let rejectFn;
73
- const got = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
74
-
75
- return {
76
- start() {
77
- return new Promise((resolve, reject) => {
78
- server = http.createServer((req, res) => {
79
- const u = new URL(req.url, 'http://127.0.0.1');
80
- if (u.pathname !== LOOPBACK_PATH) { res.writeHead(404).end(); return; }
81
- const err = u.searchParams.get('error');
82
- if (err) {
83
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
84
- .end('<h3>授权未通过</h3><p>可以关闭这个页面了。</p>');
85
- rejectFn(new Error(`授权被拒绝:${err}`));
86
- return;
87
- }
88
- const code = u.searchParams.get('code');
89
- const state = u.searchParams.get('state');
90
- if (!code) { rejectFn(new Error('回调里没有 code')); return; }
91
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
92
- .end('<h3>✅ 已收到授权</h3><p>可以关闭浏览器,回到终端了。</p>');
93
- resolveFn({ code, state });
94
- });
95
- server.once('error', reject);
96
- server.listen(0, '127.0.0.1', () => {
97
- const { port } = server.address();
98
- resolve(`http://127.0.0.1:${port}${LOOPBACK_PATH}`);
99
- });
100
- });
101
- },
102
- wait: (ms = LOGIN_TIMEOUT_MS) => Promise.race([
103
- got,
104
- new Promise((_, rej) => setTimeout(() => rej(new Error('登录超时(5 分钟未完成)')), ms)
105
- .unref?.()),
106
- ]),
107
- close() { try { server && server.close(); } catch { /* ignore */ } },
108
- };
109
- }
110
-
111
- /**
112
- * 各平台打开浏览器的命令与参数。
113
- *
114
- * ── Windows 为什么绝不借道 `cmd /c start` ────────────────────────────────
115
- * 授权 URL 里全是 `&`。Node execFile 对不含空格的参数不加引号,命令行到达
116
- * cmd.exe 后 `&` 被**当成命令分隔符**:浏览器只收到第一个 `&` 之前的部分,
117
- * redirect_uri / code_challenge 全被切掉,报「redirect_uri 未在白名单内」。
118
- * rundll32 的 FileProtocolHandler 不经任何 shell 解析,命令行原样到达。
119
- */
120
- function openBrowserArgs(platform, url) {
121
- if (platform === 'darwin') return ['open', [url]];
122
- if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]];
123
- return ['xdg-open', [url]];
124
- }
125
-
126
- function openBrowser(url) {
127
- const [cmd, args] = openBrowserArgs(process.platform, url);
128
- execFile(cmd, args, { detached: true, stdio: 'ignore' }, (e) => {
129
- if (e) {
130
- process.stderr.write(
131
- `自动打开浏览器失败(${e.message.split('\n')[0]}),请手动打开上面打印的授权地址\n`);
132
- }
133
- });
134
- }
135
-
136
- /** 向任意 URL 发 JSON / 表单请求并解析 JSON。 */
137
- function postJson(urlStr, { body, headers = {}, form } = {}) {
138
- return new Promise((resolve, reject) => {
139
- let u;
140
- try { u = new URL(urlStr); } catch { reject(new Error(`非法地址: ${urlStr}`)); return; }
141
- const payload = form ? new URLSearchParams(form).toString() : JSON.stringify(body || {});
142
- const req = http.request({
143
- method: 'POST',
144
- hostname: u.hostname,
145
- port: u.port || 80,
146
- path: u.pathname + u.search,
147
- headers: {
148
- 'Content-Type': form ? 'application/x-www-form-urlencoded' : 'application/json',
149
- 'Content-Length': Buffer.byteLength(payload),
150
- ...headers,
151
- },
152
- timeout: 15_000,
153
- }, (res) => {
154
- let raw = '';
155
- res.on('data', (c) => { raw += c; });
156
- res.on('end', () => {
157
- let json = null;
158
- try { json = JSON.parse(raw); } catch { /* 非 JSON 就当原文 */ }
159
- resolve({ status: res.statusCode, json, raw });
160
- });
161
- });
162
- req.on('timeout', () => { req.destroy(new Error('请求超时')); });
163
- req.on('error', reject);
164
- req.write(payload);
165
- req.end();
166
- });
167
- }
168
-
169
- /** GET 一个 URL 并解析 JSON。 */
170
- function getJson(urlStr, { headers = {} } = {}) {
171
- return new Promise((resolve, reject) => {
172
- let u;
173
- try { u = new URL(urlStr); } catch { reject(new Error(`非法地址: ${urlStr}`)); return; }
174
- const req = http.request({
175
- method: 'GET',
176
- hostname: u.hostname,
177
- port: u.port || 80,
178
- path: u.pathname + u.search,
179
- headers,
180
- timeout: 15_000,
181
- }, (res) => {
182
- let raw = '';
183
- res.on('data', (c) => { raw += c; });
184
- res.on('end', () => {
185
- let json = null;
186
- try { json = JSON.parse(raw); } catch { /* 非 JSON 就当原文 */ }
187
- resolve({ status: res.statusCode, json, raw });
188
- });
189
- });
190
- req.on('timeout', () => { req.destroy(new Error('请求超时')); });
191
- req.on('error', reject);
192
- req.end();
193
- });
194
- }
195
-
196
- /** 组 authorize URL。 */
197
- function buildAuthorizeUrl(cfg, req, { promptLogin = false } = {}) {
198
- const authorizeUrl = new URL(cfg.authorizationEndpoint || `${cfg.issuer}/oauth2/auth`);
199
- authorizeUrl.searchParams.set('client_id', cfg.clientId);
200
- authorizeUrl.searchParams.set('scope', (cfg.scopes || ['openid', 'profile', 'email']).join(' '));
201
- authorizeUrl.searchParams.set('state', req.state);
202
- authorizeUrl.searchParams.set('nonce', req.nonce);
203
- for (const [k, v] of Object.entries(req.authorizeParams)) authorizeUrl.searchParams.set(k, v);
204
- if (promptLogin) authorizeUrl.searchParams.set('prompt', 'login');
205
- return authorizeUrl.toString();
206
- }
207
-
208
- /**
209
- * 用 access_token 换 mn-ws 自己的设备令牌。
210
- * @returns {{userId,email,username,nickname,token,tokenExpiresAt,clientId,deviceName}}
211
- */
212
- async function verifyExchange({ serverHttp, accessToken, clientId, deviceName }) {
213
- const base = (serverHttp || '').replace(/\/+$/, '');
214
- const res = await postJson(`${base}/auth/verify`, {
215
- headers: { Authorization: `Bearer ${accessToken}` },
216
- body: { clientId, deviceName },
217
- });
218
- const j = res.json || {};
219
- if (!res.status || res.status >= 400 || !j.token) {
220
- const err = new Error(j.error || res.raw || `HTTP ${res.status}`);
221
- err.code = j.code;
222
- throw err;
223
- }
224
- return {
225
- userId: j.userId || '',
226
- email: j.email || '',
227
- username: j.username || '',
228
- nickname: j.nickname || '',
229
- token: j.token,
230
- tokenExpiresAt: j.tokenExpiresAt || 0,
231
- clientId: j.clientId || clientId || '',
232
- deviceName: j.deviceName || deviceName || '',
233
- };
234
- }
235
-
236
- /**
237
- * 完整登录一次。
238
- * @param {object} opts
239
- * @param {string} opts.serverHttp
240
- * @param {string} opts.clientId
241
- * @param {string} [opts.deviceName]
242
- * @param {boolean} [opts.noOpen] 只打印地址不弹浏览器
243
- * @param {(url:string)=>void} [opts.onUrl]
244
- * @param {boolean} [opts.promptLogin] authorize 带 prompt=login(换人登录用)
245
- */
246
- async function authLogin(opts) {
247
- const base = (opts.serverHttp || '').replace(/\/+$/, '');
248
- if (!base) throw new Error('缺少服务端地址(--url 或配置文件)');
249
-
250
- const cfgRes = await getJson(`${base}/auth/oidc/config`);
251
- const cfg = cfgRes.json || {};
252
- if (!cfg.enabled) {
253
- throw new Error('服务端未开启统一登录(MN_WS_AUTH_ENABLED)。'
254
- + '旧用法仍可用:client --token <门禁token>');
255
- }
256
- if (!cfg.issuer || !cfg.clientId) {
257
- throw new Error(`服务端统一登录配置不完整:${JSON.stringify(cfg)}`);
258
- }
259
-
260
- const lb = openLoopback();
261
- const redirectUri = await lb.start();
262
- const req = createAuthRequest(redirectUri);
263
-
264
- const authorizeUrl = new URL(buildAuthorizeUrl(cfg, req, { promptLogin: opts.promptLogin === true }));
265
-
266
- if (opts.onUrl) opts.onUrl(authorizeUrl.toString());
267
- if (opts.noOpen) {
268
- lb.close();
269
- return { authorizeUrl: authorizeUrl.toString(), redirectUri, pending: true };
270
- }
271
- openBrowser(authorizeUrl.toString());
272
-
273
- let callback;
274
- try {
275
- callback = await lb.wait();
276
- } finally {
277
- lb.close();
278
- }
279
- // state 不匹配只有两种可能:回调被串了,或有人在伪造一次登录完成。都必须中止。
280
- if (callback.state !== req.state) throw new Error('state 不一致,已中止登录');
281
-
282
- // ④ CLI 自己换 token(公共客户端 + PKCE,无 secret)
283
- const tokRes = await postJson(cfg.tokenEndpoint || `${cfg.issuer}/oauth2/token`, {
284
- form: {
285
- grant_type: 'authorization_code',
286
- code: callback.code,
287
- client_id: cfg.clientId,
288
- redirect_uri: redirectUri,
289
- code_verifier: req.verifier,
290
- },
291
- });
292
- const tok = tokRes.json || {};
293
- if (!tokRes.status || tokRes.status >= 400 || !tok.access_token) {
294
- throw new Error(`换 token 失败:${tok.error || tokRes.raw || `HTTP ${tokRes.status}`}`);
295
- }
296
-
297
- // ⑤ 换 mn-ws 自己的设备令牌
298
- const res = await verifyExchange({
299
- serverHttp: base,
300
- accessToken: tok.access_token,
301
- clientId: opts.clientId,
302
- deviceName: opts.deviceName,
303
- });
304
-
305
- return { ...res, clientId: res.clientId || opts.clientId || '' };
306
- }
307
-
308
- module.exports = {
309
- authLogin,
310
- buildAuthorizeUrl,
311
- createAuthRequest,
312
- openLoopback,
313
- openBrowser,
314
- getJson,
315
- postJson,
316
- b64url,
317
- LOOPBACK_PATH,
318
- LOGIN_TIMEOUT_MS,
319
- };