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.
- package/assets/groups.json +10 -0
- package/assets/mn-ws/SKILL.md +55 -0
- package/assets/mn-ws/hooks.json +14 -0
- package/assets/mn-ws/install/inject-root.mjs +26 -0
- package/bin/ws.mjs +13 -0
- package/dist/core/auth.d.ts +194 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +331 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/brand.d.ts +11 -0
- package/dist/core/brand.d.ts.map +1 -0
- package/dist/core/brand.js +7 -0
- package/dist/core/brand.js.map +1 -0
- package/dist/core/bridge.d.ts +106 -0
- package/dist/core/bridge.d.ts.map +1 -0
- package/dist/core/bridge.js +308 -0
- package/dist/core/bridge.js.map +1 -0
- package/dist/core/config.d.ts +174 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +417 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/controller.d.ts +64 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +100 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/errors/index.d.ts +30 -0
- package/dist/core/errors/index.d.ts.map +1 -0
- package/dist/core/errors/index.js +88 -0
- package/dist/core/errors/index.js.map +1 -0
- package/dist/core/open.d.ts +3 -0
- package/dist/core/open.d.ts.map +1 -0
- package/dist/core/open.js +27 -0
- package/dist/core/open.js.map +1 -0
- package/dist/core/paths.d.ts +15 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +50 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/serve-port.d.ts +73 -0
- package/dist/core/serve-port.d.ts.map +1 -0
- package/dist/core/serve-port.js +134 -0
- package/dist/core/serve-port.js.map +1 -0
- package/dist/core/session.d.ts +78 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +124 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/skills/bus.d.ts +29 -0
- package/dist/core/skills/bus.d.ts.map +1 -0
- package/dist/core/skills/bus.js +114 -0
- package/dist/core/skills/bus.js.map +1 -0
- package/dist/core/skills/get.d.ts +17 -0
- package/dist/core/skills/get.d.ts.map +1 -0
- package/dist/core/skills/get.js +87 -0
- package/dist/core/skills/get.js.map +1 -0
- package/dist/core/skills/handlers.d.ts +22 -0
- package/dist/core/skills/handlers.d.ts.map +1 -0
- package/dist/core/skills/handlers.js +86 -0
- package/dist/core/skills/handlers.js.map +1 -0
- package/dist/core/skills/index.d.ts +47 -0
- package/dist/core/skills/index.d.ts.map +1 -0
- package/dist/core/skills/index.js +268 -0
- package/dist/core/skills/index.js.map +1 -0
- package/dist/core/skills/install.d.ts +18 -0
- package/dist/core/skills/install.d.ts.map +1 -0
- package/dist/core/skills/install.js +123 -0
- package/dist/core/skills/install.js.map +1 -0
- package/dist/core/skills/text-util.d.ts +2 -0
- package/dist/core/skills/text-util.d.ts.map +1 -0
- package/dist/core/skills/text-util.js +14 -0
- package/dist/core/skills/text-util.js.map +1 -0
- package/dist/core/store.d.ts +29 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/store.js +100 -0
- package/dist/core/store.js.map +1 -0
- package/dist/core/views.d.ts +11 -0
- package/dist/core/views.d.ts.map +1 -0
- package/dist/core/views.js +20 -0
- package/dist/core/views.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/auth/index.d.ts +73 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +64 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/auth/service.d.ts +98 -0
- package/dist/modules/auth/service.d.ts.map +1 -0
- package/dist/modules/auth/service.js +200 -0
- package/dist/modules/auth/service.js.map +1 -0
- package/dist/modules/bridge/index.d.ts +73 -0
- package/dist/modules/bridge/index.d.ts.map +1 -0
- package/dist/modules/bridge/index.js +89 -0
- package/dist/modules/bridge/index.js.map +1 -0
- package/dist/modules/bridge/service.d.ts +75 -0
- package/dist/modules/bridge/service.d.ts.map +1 -0
- package/dist/modules/bridge/service.js +134 -0
- package/dist/modules/bridge/service.js.map +1 -0
- package/dist/modules/home/index.d.ts +95 -0
- package/dist/modules/home/index.d.ts.map +1 -0
- package/dist/modules/home/index.js +123 -0
- package/dist/modules/home/index.js.map +1 -0
- package/dist/modules/home/service.d.ts +48 -0
- package/dist/modules/home/service.d.ts.map +1 -0
- package/dist/modules/home/service.js +195 -0
- package/dist/modules/home/service.js.map +1 -0
- package/dist/modules/skill/index.d.ts +78 -0
- package/dist/modules/skill/index.d.ts.map +1 -0
- package/dist/modules/skill/index.js +132 -0
- package/dist/modules/skill/index.js.map +1 -0
- package/dist/runtime/api.d.ts +24 -0
- package/dist/runtime/api.d.ts.map +1 -0
- package/dist/runtime/api.js +140 -0
- package/dist/runtime/api.js.map +1 -0
- package/dist/runtime/cli.d.ts +23 -0
- package/dist/runtime/cli.d.ts.map +1 -0
- package/dist/runtime/cli.js +410 -0
- package/dist/runtime/cli.js.map +1 -0
- package/dist/runtime/help.d.ts +29 -0
- package/dist/runtime/help.d.ts.map +1 -0
- package/dist/runtime/help.js +89 -0
- package/dist/runtime/help.js.map +1 -0
- package/dist/runtime/registry.d.ts +318 -0
- package/dist/runtime/registry.d.ts.map +1 -0
- package/dist/runtime/registry.js +75 -0
- package/dist/runtime/registry.js.map +1 -0
- package/dist/runtime/server.d.ts +10 -0
- package/dist/runtime/server.d.ts.map +1 -0
- package/dist/runtime/server.js +127 -0
- package/dist/runtime/server.js.map +1 -0
- package/dist/runtime/spec.d.ts +32 -0
- package/dist/runtime/spec.d.ts.map +1 -0
- package/dist/runtime/spec.js +180 -0
- package/dist/runtime/spec.js.map +1 -0
- package/dist/runtime/types/action.d.ts +101 -0
- package/dist/runtime/types/action.d.ts.map +1 -0
- package/dist/runtime/types/action.js +19 -0
- package/dist/runtime/types/action.js.map +1 -0
- package/dist/runtime/types/client.d.ts +39 -0
- package/dist/runtime/types/client.d.ts.map +1 -0
- package/dist/runtime/types/client.js +2 -0
- package/dist/runtime/types/client.js.map +1 -0
- package/dist/runtime/types/http.d.ts +22 -0
- package/dist/runtime/types/http.d.ts.map +1 -0
- package/dist/runtime/types/http.js +6 -0
- package/dist/runtime/types/http.js.map +1 -0
- package/dist/runtime/types/module.d.ts +72 -0
- package/dist/runtime/types/module.d.ts.map +1 -0
- package/dist/runtime/types/module.js +15 -0
- package/dist/runtime/types/module.js.map +1 -0
- package/dist/web/public/assets/CliHints-BOxU-Duc.js +1 -0
- package/dist/web/public/assets/index-BTRKdMIW.js +50 -0
- package/dist/web/public/assets/index-BjGe8ggI.css +1 -0
- package/dist/web/public/assets/view-CE4tnCuy.js +1 -0
- package/dist/web/public/assets/view-CEQ2uPxE.js +1 -0
- package/dist/web/public/favicon-16.png +0 -0
- package/dist/web/public/favicon-32.png +0 -0
- package/dist/web/public/favicon-48.png +0 -0
- package/dist/web/public/favicon.ico +0 -0
- package/dist/web/public/index.html +19 -0
- package/dist/web/public/logo-rounded.png +0 -0
- package/dist/web/public/logo-rounded.svg +6 -0
- package/dist/web/public/logo.png +0 -0
- package/dist/web/public/logo.svg +6 -0
- package/package.json +52 -31
- package/README.md +0 -251
- package/bin/index.js +0 -714
- package/client/bridge-client.js +0 -160
- package/client/web.js +0 -234
- package/client/ws-client.js +0 -191
- package/lib/apps.js +0 -237
- package/lib/auth.js +0 -319
- package/lib/config.js +0 -534
- package/lib/index.js +0 -15
- package/lib/logger.js +0 -63
- package/lib/protocol.js +0 -208
- package/lib/store.js +0 -66
- package/server/auth.js +0 -274
- package/server/server.js +0 -752
package/lib/protocol.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 通道协议:mn-ws server 半 ⇄ client 半 之间的 WebSocket 帧格式。
|
|
5
|
-
*
|
|
6
|
-
* 帧是 JSON 文本。**请求类帧带 id 用于配对** —— 一条 WS 连接上同时跑多个
|
|
7
|
-
* HTTP 请求,响应按 id 归位(见 §多路复用)。
|
|
8
|
-
*
|
|
9
|
-
* 这套协议刻意做成**零语义**的:帧里装的就是「一次 HTTP 请求」与
|
|
10
|
-
* 「一次 HTTP 响应」,server 半原样转发、client 半原样还原。
|
|
11
|
-
* 任何业务语义(nx 的 action、鉴权规则)都不进协议层。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const PROTOCOL_VERSION = 1;
|
|
15
|
-
|
|
16
|
-
/** 帧类型 */
|
|
17
|
-
const FRAME = {
|
|
18
|
-
// server → client
|
|
19
|
-
WELCOME: 'welcome',
|
|
20
|
-
REGISTERED: 'registered',
|
|
21
|
-
REQUEST: 'request',
|
|
22
|
-
ABORT: 'abort',
|
|
23
|
-
PING: 'ping',
|
|
24
|
-
SHUTDOWN: 'shutdown',
|
|
25
|
-
ERROR: 'error',
|
|
26
|
-
// client → server
|
|
27
|
-
REGISTER: 'register',
|
|
28
|
-
RESPONSE: 'response',
|
|
29
|
-
CHUNK: 'chunk',
|
|
30
|
-
PONG: 'pong',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* 不能跨代理转发的头(hop-by-hop)。
|
|
35
|
-
*
|
|
36
|
-
* 这些头描述的是**某一次连接**的属性,转发到下一跳会出错或无意义:
|
|
37
|
-
* · Connection: keep-alive —— 新连接会自己协商
|
|
38
|
-
* · Transfer-Encoding: chunked —— body 已经被完整读成 buffer,重新算长度
|
|
39
|
-
* · Upgrade / Proxy-* —— 代理自己的协商,不该替下游决定
|
|
40
|
-
*/const HOP_BY_HOP_HEADERS = new Set([
|
|
41
|
-
'connection',
|
|
42
|
-
'keep-alive',
|
|
43
|
-
'proxy-authenticate',
|
|
44
|
-
'proxy-authorization',
|
|
45
|
-
'proxy-connection',
|
|
46
|
-
'te',
|
|
47
|
-
'trailer',
|
|
48
|
-
'transfer-encoding',
|
|
49
|
-
'upgrade',
|
|
50
|
-
]);
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 请求头里必须**剥离**的头:
|
|
54
|
-
* · hop-by-hop —— 描述的是某一次连接,不该跨代理
|
|
55
|
-
* · Origin —— nx-kv 的跨站防护只放行本机来源,透传浏览器 Origin 会 403
|
|
56
|
-
* · X-Mn-Ws-Token —— **mn-ws 自己的 L1 凭据**,属于桥接层内部事务,
|
|
57
|
-
* 泄给目标服务等于把门禁令牌交出去
|
|
58
|
-
*/
|
|
59
|
-
const STRIP_REQUEST_HEADERS = new Set([
|
|
60
|
-
...HOP_BY_HOP_HEADERS,
|
|
61
|
-
'origin',
|
|
62
|
-
'x-mn-ws-token',
|
|
63
|
-
]);
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* 响应头里不能转发的头。
|
|
67
|
-
*
|
|
68
|
-
* `content-length` 会在 client half 重新按实际 body 长度计算;
|
|
69
|
-
* `content-encoding` 由 client half 的 http 客户端处理,body 到手时已解码。
|
|
70
|
-
*/
|
|
71
|
-
const STRIP_RESPONSE_HEADERS = new Set([...HOP_BY_HOP_HEADERS, 'content-length']);
|
|
72
|
-
|
|
73
|
-
/** 请求体上限(字节)。与 nx-kv 的 24MB 对齐;base64 后约 32MB。 */
|
|
74
|
-
const MAX_BODY_BYTES = 24 * 1024 * 1024;
|
|
75
|
-
|
|
76
|
-
/** 单个 HTTP 请求在 server 半的最长等待时间(毫秒)。 */
|
|
77
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
78
|
-
|
|
79
|
-
/** 帧编码:把一个对象序列化成 WS 文本帧。 */
|
|
80
|
-
function encodeFrame(obj) {
|
|
81
|
-
return JSON.stringify(obj);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 帧解码。返回 null 表示「这不是一帧合法的协议消息」——
|
|
86
|
-
* 调用方应当忽略并计数,而不是把解析异常抛给连接层。
|
|
87
|
-
*/
|
|
88
|
-
function decodeFrame(raw) {
|
|
89
|
-
if (raw === null || raw === undefined) return null;
|
|
90
|
-
let text;
|
|
91
|
-
if (typeof raw === 'string') text = raw;
|
|
92
|
-
else if (Buffer.isBuffer(raw)) text = raw.toString('utf8');
|
|
93
|
-
else if (Array.isArray(raw)) text = Buffer.concat(raw).toString('utf8');
|
|
94
|
-
else return null;
|
|
95
|
-
|
|
96
|
-
let obj;
|
|
97
|
-
try {
|
|
98
|
-
obj = JSON.parse(text);
|
|
99
|
-
} catch {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return null;
|
|
103
|
-
return obj;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* 生成一个跨连接唯一的请求 id。
|
|
108
|
-
*
|
|
109
|
-
* server 半的 pending 表是**全局**的(不按连接分表),所以 id 必须全局唯一:
|
|
110
|
-
* 只带连接内计数的话,两条连接上的 "1" 会撞车,响应会被配到错误的请求上。
|
|
111
|
-
*/
|
|
112
|
-
let _seq = 0;
|
|
113
|
-
function nextRequestId(prefix = 'r') {
|
|
114
|
-
_seq = (_seq + 1) % 0xffffff;
|
|
115
|
-
const rand = Math.random().toString(36).slice(2, 8);
|
|
116
|
-
return `${prefix}-${Date.now().toString(36)}-${_seq.toString(36)}-${rand}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** 头对象规范化:键统一小写,值统一成数组。 */
|
|
120
|
-
function normalizeHeaders(headers) {
|
|
121
|
-
const out = {};
|
|
122
|
-
if (!headers || typeof headers !== 'object') return out;
|
|
123
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
124
|
-
const key = String(k).toLowerCase();
|
|
125
|
-
if (v === undefined || v === null) continue;
|
|
126
|
-
out[key] = Array.isArray(v) ? v.map((x) => String(x)) : [String(v)];
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** 取头的第一个值(大小写不敏感)。 */
|
|
132
|
-
function headerValue(headers, name) {
|
|
133
|
-
if (!headers) return undefined;
|
|
134
|
-
const key = String(name).toLowerCase();
|
|
135
|
-
const v = headers[key];
|
|
136
|
-
if (!v) return undefined;
|
|
137
|
-
return Array.isArray(v) ? v[0] : v;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** 剔除不该转发的请求头。 */
|
|
141
|
-
function stripRequestHeaders(headers) {
|
|
142
|
-
const out = {};
|
|
143
|
-
for (const [k, v] of Object.entries(headers || {})) {
|
|
144
|
-
if (STRIP_REQUEST_HEADERS.has(k)) continue;
|
|
145
|
-
out[k] = v;
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** 剔除不该转发的响应头。 */
|
|
151
|
-
function stripResponseHeaders(headers) {
|
|
152
|
-
const out = {};
|
|
153
|
-
for (const [k, v] of Object.entries(headers || {})) {
|
|
154
|
-
if (STRIP_RESPONSE_HEADERS.has(k)) continue;
|
|
155
|
-
out[k] = v;
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* 把协议层的头(值统一是数组)压平成 Node http 能吃的形状。
|
|
162
|
-
*
|
|
163
|
-
* ## 为什么需要这一步
|
|
164
|
-
*
|
|
165
|
-
* 协议层把头的值统一存成数组,是为了**保住 set-cookie 这类多值头**
|
|
166
|
-
* (一个响应里可以有几十条 Set-Cookie,用 string 就只剩最后一条)。
|
|
167
|
-
* 但 Node 的 `http.request({headers})` 与 `res.writeHead(status, headers)`
|
|
168
|
-
* 要求**除 set-cookie 外全是字符串** —— 直接把数组塞进去会抛
|
|
169
|
-
* `options.headers.host property must be of type string. Received an instance of Array`。
|
|
170
|
-
*
|
|
171
|
-
* @param {object} headers 规范化后的头
|
|
172
|
-
* @param {string[]} [arrayAllowed] 允许保留数组的键(响应侧用 set-cookie)
|
|
173
|
-
*/
|
|
174
|
-
function flattenHeaders(headers, arrayAllowed = []) {
|
|
175
|
-
const out = {};
|
|
176
|
-
for (const [k, v] of Object.entries(headers || {})) {
|
|
177
|
-
if (v === undefined || v === null) continue;
|
|
178
|
-
if (Array.isArray(v)) {
|
|
179
|
-
if (arrayAllowed.includes(k)) {
|
|
180
|
-
if (v.length) out[k] = v.map((x) => String(x));
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (!v.length) continue;
|
|
184
|
-
out[k] = String(v[0]);
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
out[k] = String(v);
|
|
188
|
-
}
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
module.exports = {
|
|
193
|
-
PROTOCOL_VERSION,
|
|
194
|
-
FRAME,
|
|
195
|
-
HOP_BY_HOP_HEADERS,
|
|
196
|
-
STRIP_REQUEST_HEADERS,
|
|
197
|
-
STRIP_RESPONSE_HEADERS,
|
|
198
|
-
MAX_BODY_BYTES,
|
|
199
|
-
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
200
|
-
encodeFrame,
|
|
201
|
-
decodeFrame,
|
|
202
|
-
nextRequestId,
|
|
203
|
-
normalizeHeaders,
|
|
204
|
-
headerValue,
|
|
205
|
-
stripRequestHeaders,
|
|
206
|
-
stripResponseHeaders,
|
|
207
|
-
flattenHeaders,
|
|
208
|
-
};
|
package/lib/store.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 审计落盘:每次经桥的请求记一行 JSONL。
|
|
5
|
-
*
|
|
6
|
-
* 为什么必须落盘:mn-ws server 是**新的公网暴露面**,它能代理到本机的
|
|
7
|
-
* 十几个服务。出事时「谁在什么时候调了哪个 app 的哪个路径」必须查得到,
|
|
8
|
-
* 否则就是一笔糊涂账。
|
|
9
|
-
*
|
|
10
|
-
* 与 mn-rt 的 client/lib/store.js 同款:JSONL 追加,尾部读。
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const fs = require('node:fs');
|
|
14
|
-
const path = require('node:path');
|
|
15
|
-
|
|
16
|
-
class Store {
|
|
17
|
-
constructor({ dir = null, name = 'requests.jsonl', maxTail = 200 } = {}) {
|
|
18
|
-
this.dir = dir;
|
|
19
|
-
this.file = dir ? path.join(dir, name) : null;
|
|
20
|
-
this.maxTail = maxTail;
|
|
21
|
-
this._tail = [];
|
|
22
|
-
if (this.file) {
|
|
23
|
-
try {
|
|
24
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
-
this._tail = readTail(this.file, this.maxTail);
|
|
26
|
-
} catch { /* ignore */ }
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 追加一条记录。 */
|
|
31
|
-
append(record) {
|
|
32
|
-
const row = { ts: Date.now(), ...record };
|
|
33
|
-
this._tail.push(row);
|
|
34
|
-
if (this._tail.length > this.maxTail) this._tail.shift();
|
|
35
|
-
if (this.file) {
|
|
36
|
-
try {
|
|
37
|
-
fs.appendFileSync(this.file, `${JSON.stringify(row)}\n`);
|
|
38
|
-
} catch { /* 落盘失败不阻断主流程 */ }
|
|
39
|
-
}
|
|
40
|
-
return row;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** 最近若干条(内存里已有一份,进程起来就能读)。 */
|
|
44
|
-
tail(n = 50) {
|
|
45
|
-
const k = Math.max(0, this._tail.length - n);
|
|
46
|
-
return this._tail.slice(k);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
stats() {
|
|
50
|
-
return { count: this._tail.length, file: this.file };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function readTail(file, max) {
|
|
55
|
-
try {
|
|
56
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
57
|
-
const lines = text.split('\n').filter(Boolean);
|
|
58
|
-
return lines.slice(-max).map((l) => {
|
|
59
|
-
try { return JSON.parse(l); } catch { return { raw: l }; }
|
|
60
|
-
});
|
|
61
|
-
} catch {
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
module.exports = { Store };
|
package/server/auth.js
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* mn-ws server 侧的认证:统一登录的公开参数 + 设备令牌的签发与校验。
|
|
5
|
-
*
|
|
6
|
-
* 与 mn-rt 的 server/src/auth.js 同构,但**没有 sessionKey**:
|
|
7
|
-
* mn-ws 桥接的是 HTTP 请求,不是加密信封流,桥接层不需要「会话密钥」概念。
|
|
8
|
-
*
|
|
9
|
-
* 两类凭据:
|
|
10
|
-
* · 认证中心 access_token —— 短命,登录当场换设备令牌用完即弃,不落盘。
|
|
11
|
-
* · mn-ws 设备令牌(HS256 JWT)—— 长命,落盘,由本服务签发与吊销。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const crypto = require('crypto');
|
|
15
|
-
const fs = require('node:fs');
|
|
16
|
-
const path = require('node:path');
|
|
17
|
-
const http = require('node:http');
|
|
18
|
-
|
|
19
|
-
/** 设备令牌有效期(秒)。默认 90 天 —— 桥接是公网暴露面,令牌不宜过长。 */
|
|
20
|
-
const DEVICE_TOKEN_TTL_SEC = 90 * 24 * 3600;
|
|
21
|
-
|
|
22
|
-
function b64url(buf) {
|
|
23
|
-
return Buffer.from(buf).toString('base64')
|
|
24
|
-
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function b64urlJson(obj) {
|
|
28
|
-
return b64url(Buffer.from(JSON.stringify(obj), 'utf8'));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function fromB64url(s) {
|
|
32
|
-
const pad = '='.repeat((4 - (s.length % 4)) % 4);
|
|
33
|
-
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
class AuthHub {
|
|
37
|
-
constructor(opts = {}) {
|
|
38
|
-
this.enabled = opts.enabled === true;
|
|
39
|
-
this.issuer = opts.issuer || '';
|
|
40
|
-
this.clientId = opts.clientId || '';
|
|
41
|
-
this.redirectUri = opts.redirectUri || '';
|
|
42
|
-
this.scopes = opts.scopes || ['openid', 'profile', 'email'];
|
|
43
|
-
this.jwtSecret = opts.jwtSecret || '';
|
|
44
|
-
this.httpTimeoutMs = opts.httpTimeoutMs || 10_000;
|
|
45
|
-
this.dataDir = opts.dataDir || null;
|
|
46
|
-
|
|
47
|
-
/** 发现文档缓存。只有成功才固化——失败若被记住,容器编排下
|
|
48
|
-
* "本服务先于认证中心启动" 会让统一登录永久不可用直到重启。 */
|
|
49
|
-
this._endpoints = null;
|
|
50
|
-
|
|
51
|
-
/** clientId -> {userId, deviceName, registeredAt, lastSeenAt} */
|
|
52
|
-
this.devices = new Map();
|
|
53
|
-
/** jti -> 过期时间戳(吊销名单) */
|
|
54
|
-
this.revoked = new Map();
|
|
55
|
-
this._loadDevices();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ==================== 配置与发现 ====================
|
|
59
|
-
|
|
60
|
-
/** 下发给客户端的公开参数。**绝不含任何 secret**。 */
|
|
61
|
-
publicConfig() {
|
|
62
|
-
const e = this._endpoints;
|
|
63
|
-
return {
|
|
64
|
-
enabled: this.enabled,
|
|
65
|
-
issuer: this.issuer,
|
|
66
|
-
clientId: this.clientId,
|
|
67
|
-
redirectUri: this.redirectUri,
|
|
68
|
-
scopes: this.scopes,
|
|
69
|
-
authorizationEndpoint: e ? e.authorization_endpoint : `${this.issuer}/oauth2/auth`,
|
|
70
|
-
tokenEndpoint: e ? e.token_endpoint : `${this.issuer}/oauth2/token`,
|
|
71
|
-
idpAvailable: !!e,
|
|
72
|
-
...(e ? {} : { idpError: this._discoverError || '未拉取到发现文档' }),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async discover() {
|
|
77
|
-
if (this._endpoints) return this._endpoints;
|
|
78
|
-
if (!this.enabled) throw Object.assign(new Error('统一登录未启用'), { code: 'disabled' });
|
|
79
|
-
const url = `${this.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
|
|
80
|
-
const doc = await this._fetchJson(url, { method: 'GET' });
|
|
81
|
-
if (!doc || !doc.token_endpoint) {
|
|
82
|
-
throw Object.assign(new Error('发现文档不完整'), { code: 'bad_discovery' });
|
|
83
|
-
}
|
|
84
|
-
this._endpoints = doc;
|
|
85
|
-
return doc;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async _fetchJson(urlStr, { method = 'GET', headers = {}, body = null } = {}) {
|
|
89
|
-
return new Promise((resolve, reject) => {
|
|
90
|
-
let u;
|
|
91
|
-
try { u = new URL(urlStr); } catch { reject(new Error(`非法地址: ${urlStr}`)); return; }
|
|
92
|
-
const payload = body === null ? '' : (typeof body === 'string' ? body : JSON.stringify(body));
|
|
93
|
-
const req = http.request({
|
|
94
|
-
method,
|
|
95
|
-
hostname: u.hostname,
|
|
96
|
-
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
97
|
-
path: u.pathname + u.search,
|
|
98
|
-
headers: {
|
|
99
|
-
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
100
|
-
...headers,
|
|
101
|
-
},
|
|
102
|
-
timeout: this.httpTimeoutMs,
|
|
103
|
-
}, (res) => {
|
|
104
|
-
let raw = '';
|
|
105
|
-
res.on('data', (c) => { raw += c; });
|
|
106
|
-
res.on('end', () => {
|
|
107
|
-
let json = null;
|
|
108
|
-
try { json = JSON.parse(raw); } catch { /* 非 JSON 就当原文 */ }
|
|
109
|
-
resolve(json || raw);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
req.on('timeout', () => { req.destroy(new Error('请求超时')); });
|
|
113
|
-
req.on('error', reject);
|
|
114
|
-
if (payload) req.write(payload);
|
|
115
|
-
req.end();
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ==================== 身份 ====================
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 校验一枚**认证中心**的 access_token,返回身份。
|
|
123
|
-
*
|
|
124
|
-
* 与 mn-rt 的分工一致:exchange 是"我有 code,服务器你帮我换",
|
|
125
|
-
* verifyBearer 是"我有 token,服务器你帮我确认是谁"。两条路最终都收敛到
|
|
126
|
-
* 同一个结论——认证中心自己断言的 sub,所以属主判定只有一份实现。
|
|
127
|
-
*/
|
|
128
|
-
async verifyBearer(accessToken) {
|
|
129
|
-
if (!accessToken) throw Object.assign(new Error('缺少 access_token'), { code: 'bad_request' });
|
|
130
|
-
const doc = await this.discover();
|
|
131
|
-
const ui = await this._fetchJson(doc.userinfo_endpoint || `${this.issuer}/oauth2/userinfo`, {
|
|
132
|
-
headers: { Authorization: `Bearer ${accessToken}` },
|
|
133
|
-
});
|
|
134
|
-
if (!ui || ui.error) {
|
|
135
|
-
throw Object.assign(new Error(`令牌无效: ${ui && ui.error ? ui.error : '无 sub'}`),
|
|
136
|
-
{ code: (ui && ui.error) || 'invalid_token' });
|
|
137
|
-
}
|
|
138
|
-
if (!ui.sub) throw Object.assign(new Error('userinfo 未返回 sub'), { code: 'no_sub' });
|
|
139
|
-
return {
|
|
140
|
-
userId: String(ui.sub),
|
|
141
|
-
sub: String(ui.sub),
|
|
142
|
-
email: ui.email || '',
|
|
143
|
-
name: ui.name || '',
|
|
144
|
-
preferredUsername: ui.preferred_username || '',
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ==================== 设备令牌 ====================
|
|
149
|
-
|
|
150
|
-
/** 签发设备令牌。HS256 JWT,payload 带 userId 与 clientId。 */
|
|
151
|
-
issueDeviceToken({ userId, clientId, deviceName }) {
|
|
152
|
-
if (!this.jwtSecret) {
|
|
153
|
-
throw Object.assign(new Error('未配置 MN_WS_JWT_SECRET'), { code: 'no_secret' });
|
|
154
|
-
}
|
|
155
|
-
const now = Math.floor(Date.now() / 1000);
|
|
156
|
-
const header = { alg: 'HS256', typ: 'JWT' };
|
|
157
|
-
const payload = {
|
|
158
|
-
sub: userId,
|
|
159
|
-
cid: clientId,
|
|
160
|
-
dn: deviceName || '',
|
|
161
|
-
iat: now,
|
|
162
|
-
exp: now + DEVICE_TOKEN_TTL_SEC,
|
|
163
|
-
// jti 让单枚令牌可被单独吊销
|
|
164
|
-
jti: crypto.randomBytes(9).toString('hex'),
|
|
165
|
-
};
|
|
166
|
-
const head = b64urlJson(header);
|
|
167
|
-
const body = b64urlJson(payload);
|
|
168
|
-
const sig = b64url(crypto.createHmac('sha256', this.jwtSecret).update(`${head}.${body}`).digest());
|
|
169
|
-
return {
|
|
170
|
-
token: `${head}.${body}.${sig}`,
|
|
171
|
-
expiresAt: (now + DEVICE_TOKEN_TTL_SEC) * 1000,
|
|
172
|
-
jti: payload.jti,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** 校验设备令牌。返回 payload 或 null。 */
|
|
177
|
-
verifyDeviceToken(token) {
|
|
178
|
-
if (!this.jwtSecret || !token || typeof token !== 'string') return null;
|
|
179
|
-
const parts = token.split('.');
|
|
180
|
-
if (parts.length !== 3) return null;
|
|
181
|
-
const [head, body, sig] = parts;
|
|
182
|
-
const want = b64url(crypto.createHmac('sha256', this.jwtSecret).update(`${head}.${body}`).digest());
|
|
183
|
-
// 常量时间比较:这里比的是"能不能冒充某个用户的一台桥"
|
|
184
|
-
const a = Buffer.from(sig);
|
|
185
|
-
const b = Buffer.from(want);
|
|
186
|
-
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
187
|
-
let payload;
|
|
188
|
-
try { payload = JSON.parse(fromB64url(body).toString('utf8')); } catch { return null; }
|
|
189
|
-
if (!payload || !payload.sub) return null;
|
|
190
|
-
if (typeof payload.exp !== 'number' || Math.floor(Date.now() / 1000) >= payload.exp) return null;
|
|
191
|
-
if (this._isRevoked(payload.jti)) return null;
|
|
192
|
-
return payload;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** 吊销某枚令牌。名单落盘 —— 否则重启即"全部恢复",而吊销往往正是因为设备丢了。 */
|
|
196
|
-
revoke(jti) {
|
|
197
|
-
if (!jti) return;
|
|
198
|
-
this.revoked.set(jti, Date.now() + DEVICE_TOKEN_TTL_SEC * 1000);
|
|
199
|
-
this._saveDevices();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
_isRevoked(jti) {
|
|
203
|
-
if (!this.revoked || !jti) return false;
|
|
204
|
-
const until = this.revoked.get(jti);
|
|
205
|
-
if (!until) return false;
|
|
206
|
-
if (until < Date.now()) { this.revoked.delete(jti); return false; }
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ==================== 设备登记 ====================
|
|
211
|
-
|
|
212
|
-
/** 登记一台桥(client half):clientId → 属主。永久一对一。 */
|
|
213
|
-
registerDevice({ clientId, userId, deviceName }) {
|
|
214
|
-
if (!clientId) throw Object.assign(new Error('缺少 clientId'), { code: 'bad_request' });
|
|
215
|
-
const existing = this.devices.get(clientId);
|
|
216
|
-
if (existing && existing.userId !== userId) {
|
|
217
|
-
throw Object.assign(new Error('该标识已属于别的账号'), { code: 'device_owned_by_other' });
|
|
218
|
-
}
|
|
219
|
-
const rec = {
|
|
220
|
-
userId,
|
|
221
|
-
clientId,
|
|
222
|
-
deviceName: deviceName || (existing && existing.deviceName) || '',
|
|
223
|
-
registeredAt: existing ? existing.registeredAt : new Date().toISOString(),
|
|
224
|
-
lastSeenAt: new Date().toISOString(),
|
|
225
|
-
};
|
|
226
|
-
this.devices.set(clientId, rec);
|
|
227
|
-
this._saveDevices();
|
|
228
|
-
return rec;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
getDevice(clientId) {
|
|
232
|
-
return this.devices.get(clientId) || null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
devicesOf(userId) {
|
|
236
|
-
return [...this.devices.values()].filter((d) => d.userId === userId);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ==================== 落盘 ====================
|
|
240
|
-
|
|
241
|
-
_devicesFile() {
|
|
242
|
-
return this.dataDir ? path.join(this.dataDir, 'devices.json') : null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
_loadDevices() {
|
|
246
|
-
const f = this._devicesFile();
|
|
247
|
-
if (!f) return;
|
|
248
|
-
try {
|
|
249
|
-
if (!fs.existsSync(f)) return;
|
|
250
|
-
const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
251
|
-
this.devices = new Map(Object.entries(raw.devices || {}));
|
|
252
|
-
this.revoked = new Map(Object.entries(raw.revoked || {}));
|
|
253
|
-
} catch {
|
|
254
|
-
this.devices = new Map();
|
|
255
|
-
this.revoked = new Map();
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
_saveDevices() {
|
|
260
|
-
const f = this._devicesFile();
|
|
261
|
-
if (!f) return;
|
|
262
|
-
try {
|
|
263
|
-
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
264
|
-
const tmp = `${f}.${process.pid}.tmp`;
|
|
265
|
-
fs.writeFileSync(tmp, JSON.stringify({
|
|
266
|
-
devices: Object.fromEntries(this.devices),
|
|
267
|
-
revoked: Object.fromEntries(this.revoked),
|
|
268
|
-
}, null, 2), { mode: 0o600 });
|
|
269
|
-
fs.renameSync(tmp, f);
|
|
270
|
-
} catch { /* 落盘失败不阻断主流程 */ }
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
module.exports = { AuthHub, DEVICE_TOKEN_TTL_SEC, b64url, fromB64url };
|