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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mn-ws
|
|
3
|
+
description: HTTP 与 WebSocket 双向桥的客户端半:把本机 nx 服务桥给远端 HTTP 客户端。当用户要求操作本项目(启动服务/面板、查看路由与健康状态、安装本 skill、了解可用命令)时使用。触发词:mn-ws、启动面板、skill install、查看路由、健康检查。
|
|
4
|
+
---
|
|
5
|
+
# mn-ws skill
|
|
6
|
+
|
|
7
|
+
本项目是「CLI 与 Web 面板同源」的本机工具:一条 action 声明同时派生出
|
|
8
|
+
CLI 命令与 HTTP 路由,Web 上能做的,CLI 都能做。
|
|
9
|
+
|
|
10
|
+
- 项目根(安装时注入,固定值):`{{PROJECT_ROOT}}`
|
|
11
|
+
|
|
12
|
+
## 常用命令
|
|
13
|
+
|
|
14
|
+
在项目根目录执行:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 启动 Web 面板(默认端口 7777)
|
|
18
|
+
node bin/ws.mjs serve # 开发期:pnpm dev
|
|
19
|
+
|
|
20
|
+
# 查看全部命令与 HTTP 路由对照
|
|
21
|
+
node bin/ws.mjs routes
|
|
22
|
+
|
|
23
|
+
# 健康检查
|
|
24
|
+
node bin/ws.mjs health
|
|
25
|
+
|
|
26
|
+
# skill:列出 / 导出 / 安装
|
|
27
|
+
node bin/ws.mjs skill list
|
|
28
|
+
node bin/ws.mjs skill get mn-ws
|
|
29
|
+
node bin/ws.mjs skill install mn-ws
|
|
30
|
+
node bin/ws.mjs skill install --all # 一次装全部 skill(与 name / --group 互斥)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
加 `--json` 可获取机器可读输出(错误时同样输出 JSON)。
|
|
34
|
+
|
|
35
|
+
## 端口与浏览器
|
|
36
|
+
|
|
37
|
+
- `serve` 端口被占时不会报错:上面已经是本项目的面板就**直接打开它**(不重复启动),
|
|
38
|
+
是别的服务(或探测不出来)就顺延到下一个空闲端口再起自己的面板
|
|
39
|
+
- 两种情况都会自动打开浏览器;`NX_SERVE_NO_OPEN=1`(所有 nx-xx 项目共用的同一个
|
|
40
|
+
环境变量)可全局关掉自动开浏览器,`--no-open` 只影响这一次调用
|
|
41
|
+
- `pnpm dev` 同样先认领后端端口:自己的面板就复用(不再起第二个 serve),别的服务就
|
|
42
|
+
顺延到下一个空闲端口(vite 的代理目标会跟着一起走);两种情况都会把浏览器指到 dev 地址
|
|
43
|
+
|
|
44
|
+
## 典型会话
|
|
45
|
+
|
|
46
|
+
1. 用户要求启动面板 → `serve`,确认端口可访问
|
|
47
|
+
2. 用户问「有哪些接口/命令」→ `routes`
|
|
48
|
+
3. 用户要把本 skill 装给 agent → `skill install mn-ws`
|
|
49
|
+
(安装钩子会校验当前目录是本项目根,并注入项目根路径)
|
|
50
|
+
|
|
51
|
+
## 约束
|
|
52
|
+
|
|
53
|
+
- 操作本项目一律在项目根目录内调用本地 CLI
|
|
54
|
+
- 本 skill 只能在 mn-ws 项目根目录安装:非该目录会被安装钩子拒绝
|
|
55
|
+
- 命令、参数与 flag 的权威清单看 `routes --json`,不要凭记忆杜撰
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// skill:pre-install 的 module handler(经本 skill 的 hooks.json 注册)。
|
|
2
|
+
//
|
|
3
|
+
// cwd 不是本项目根(package.json name 不匹配)→ { block },拒绝安装;
|
|
4
|
+
// 是 → { vars: { PROJECT_ROOT } },把项目根全路径渲染进 SKILL.md。
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export default async function injectRoot(payload) {
|
|
9
|
+
const root = payload.cwd;
|
|
10
|
+
let pkgName = null;
|
|
11
|
+
try {
|
|
12
|
+
pkgName = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name;
|
|
13
|
+
} catch {
|
|
14
|
+
pkgName = null;
|
|
15
|
+
}
|
|
16
|
+
if (pkgName !== 'mn-ws') {
|
|
17
|
+
return {
|
|
18
|
+
block: [
|
|
19
|
+
`mn-ws skill 只能在 mn-ws 项目根目录内安装,当前目录不是:${root}`,
|
|
20
|
+
'判定条件:package.json 的 name 为 "mn-ws"。',
|
|
21
|
+
`请先 cd 到 mn-ws 项目根目录,再重新执行 skill install mn-ws。`,
|
|
22
|
+
].join('\n'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { vars: { PROJECT_ROOT: root.replace(/\\/g, '/') } };
|
|
26
|
+
}
|
package/bin/ws.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mn-ws 唯一入口。
|
|
3
|
+
//
|
|
4
|
+
// 设计约定:Web 面板上每个按钮,都对应这里的一条 CLI 命令——二者由同一份 action
|
|
5
|
+
// 声明派生(见 src/modules/*/index.ts),不是靠人维护两份清单。
|
|
6
|
+
//
|
|
7
|
+
// 运行的是构建产物 dist/(Node 侧 tsc 输出)。发布前必须先 pnpm build。
|
|
8
|
+
import { runCli } from '../dist/runtime/cli.js';
|
|
9
|
+
|
|
10
|
+
runCli(process.argv.slice(2)).catch((err) => {
|
|
11
|
+
console.error(err && err.stack ? err.stack : String(err));
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client 半的统一登录:PKCE 授权码流程。
|
|
3
|
+
*
|
|
4
|
+
* ## 两种发起方式,同一套协议
|
|
5
|
+
*
|
|
6
|
+
* · **CLI**(`mn-ws auth login`):在**随机端口**起一次性回环监听,
|
|
7
|
+
* 浏览器把授权码送回来,CLI 自己换 token。
|
|
8
|
+
* · **Web 面板**(面板里点「登录」):在**面板自己的端口**上收码
|
|
9
|
+
* (路径固定 /callback),面板后端换 token。
|
|
10
|
+
*
|
|
11
|
+
* 两者用的是同一个 client_id(`mn-ws-client`),回调白名单是
|
|
12
|
+
* `http://127.0.0.1:<任意端口>/callback` —— 端口通配、路径固定,主机与路径都严格受限。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 上面那句「任意端口」在别处写成字面量会出事:端口通配的形态是
|
|
15
|
+
* 「星号紧跟斜杠」,出现在**块注释里**会把注释提前闭合 ——
|
|
16
|
+
* 文件后半截被当成代码,报一堆莫名其妙的语法错。mn-rt 真实踩过一次。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么 CLI 自己换 token(而不是交给 server 换)
|
|
19
|
+
*
|
|
20
|
+
* 交给 server 换需要 server 自己发起 authorize 并持有 PKCE verifier ——
|
|
21
|
+
* 那就多一份 RP 会话(cookie/存储)要实现。CLI 自己换 token 只需登记
|
|
22
|
+
* 回环通配(127.0.0.1 端口任意、路径固定 /callback)—— RFC 8252 给原生
|
|
23
|
+
* 客户端的标准做法。
|
|
24
|
+
*/
|
|
25
|
+
/** CLI 侧回环回调路径。端口 0 让内核分配,再回填进 authorize 请求。 */
|
|
26
|
+
export declare const LOOPBACK_PATH = "/callback";
|
|
27
|
+
/** 等用户完成登录的时间上限(5 分钟)。 */
|
|
28
|
+
export declare const LOGIN_TIMEOUT_MS: number;
|
|
29
|
+
export declare function b64url(buf: Buffer | string): string;
|
|
30
|
+
/**
|
|
31
|
+
* 取「发起授权码流程时该用的 client_id」。
|
|
32
|
+
*
|
|
33
|
+
* 两端拆开之后:**server 半有它自己的身份**(mn-ws-server),而真正发起授权码
|
|
34
|
+
* 流程的是 **client 半**(mn-ws-client)。server 半通过 /auth/oidc/config 把
|
|
35
|
+
* 后者一并发布出来,所以新装的 client 半不需要额外配置 —— 与 mn-rt
|
|
36
|
+
* 「server 告诉 client 用哪个 client_id」的做法一致,只是这里明确分成了两个身份。
|
|
37
|
+
*
|
|
38
|
+
* 回落 cfg.clientId:老的 server 只发布一个 client_id,那时的登录用的就是它。
|
|
39
|
+
*/
|
|
40
|
+
export declare function loginClientIdOf(cfg: {
|
|
41
|
+
loginClientId?: string;
|
|
42
|
+
clientId?: string;
|
|
43
|
+
} | null | undefined): string;
|
|
44
|
+
/** 一次授权请求的 PKCE 参数与一次性参数。 */
|
|
45
|
+
export interface AuthRequest {
|
|
46
|
+
verifier: string;
|
|
47
|
+
challenge: string;
|
|
48
|
+
state: string;
|
|
49
|
+
nonce: string;
|
|
50
|
+
authorizeParams: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 生成 PKCE 与一次性参数。
|
|
54
|
+
*
|
|
55
|
+
* 注意 state 只有**一个**值:authorizeParams 里的那个就是真正发出去的,
|
|
56
|
+
* 也是回调回来后要比对的。曾经同时返回两个独立随机值,导致每次登录都必然
|
|
57
|
+
* 报 state 不一致 —— 这里只留一份。
|
|
58
|
+
*/
|
|
59
|
+
export declare function createAuthRequest(redirectUri: string): AuthRequest;
|
|
60
|
+
/** 认证中心下发的公开参数(来自 server 半的 /auth/oidc/config)。 */
|
|
61
|
+
export interface IdpConfig {
|
|
62
|
+
enabled?: boolean;
|
|
63
|
+
issuer?: string;
|
|
64
|
+
clientId?: string;
|
|
65
|
+
loginClientId?: string;
|
|
66
|
+
authorizationEndpoint?: string;
|
|
67
|
+
tokenEndpoint?: string;
|
|
68
|
+
scopes?: string[];
|
|
69
|
+
[k: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
/** 组 authorize URL。 */
|
|
72
|
+
export declare function buildAuthorizeUrl(cfg: IdpConfig, req: AuthRequest, { promptLogin }?: {
|
|
73
|
+
promptLogin?: boolean | undefined;
|
|
74
|
+
}): string;
|
|
75
|
+
/** 回环监听器。 */
|
|
76
|
+
export interface Loopback {
|
|
77
|
+
/** 起监听;port=0 让内核分配(CLI),port=N 用固定端口(面板)。 */
|
|
78
|
+
start(): Promise<string>;
|
|
79
|
+
wait(ms?: number): Promise<{
|
|
80
|
+
code: string;
|
|
81
|
+
state: string;
|
|
82
|
+
}>;
|
|
83
|
+
close(): void;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 起一次性回环监听,等浏览器把 code 送回来。
|
|
87
|
+
*
|
|
88
|
+
* 只绑 127.0.0.1:绑 0.0.0.0 会让同网段任何人都能来抢这个 code。
|
|
89
|
+
*/
|
|
90
|
+
export declare function openLoopback({ port }?: {
|
|
91
|
+
port?: number;
|
|
92
|
+
}): Loopback;
|
|
93
|
+
/**
|
|
94
|
+
* 各平台打开浏览器的命令与参数。
|
|
95
|
+
*
|
|
96
|
+
* ── Windows 为什么绝不借道 `cmd /c start` ────────────────────────────────
|
|
97
|
+
* 授权 URL 里全是 `&`。Node execFile 对不含空格的参数不加引号,命令行到达
|
|
98
|
+
* cmd.exe 后 `&` 被**当成命令分隔符**:浏览器只收到第一个 `&` 之前的部分,
|
|
99
|
+
* redirect_uri / code_challenge 全被切掉,报「redirect_uri 未在白名单内」。
|
|
100
|
+
* rundll32 的 FileProtocolHandler 不经任何 shell 解析,命令行原样到达。
|
|
101
|
+
*/
|
|
102
|
+
export declare function openBrowserArgs(platform: string, url: string): [string, string[]];
|
|
103
|
+
export declare function openBrowser(url: string): void;
|
|
104
|
+
/** 一个 HTTP JSON 响应。 */
|
|
105
|
+
export interface JsonResponse {
|
|
106
|
+
status: number;
|
|
107
|
+
json: Record<string, unknown> | null;
|
|
108
|
+
raw: string;
|
|
109
|
+
}
|
|
110
|
+
export declare function getJson(url: string): Promise<JsonResponse>;
|
|
111
|
+
/** POST 一个 form(application/x-www-form-urlencoded)。 */
|
|
112
|
+
export declare function postJson(url: string, { form }: {
|
|
113
|
+
form: Record<string, string>;
|
|
114
|
+
}): Promise<JsonResponse>;
|
|
115
|
+
/** 登录的最终产物。 */
|
|
116
|
+
export interface LoginResult {
|
|
117
|
+
userId?: string;
|
|
118
|
+
email?: string;
|
|
119
|
+
username?: string;
|
|
120
|
+
nickname?: string;
|
|
121
|
+
token?: string;
|
|
122
|
+
tokenExpiresAt?: number;
|
|
123
|
+
clientId?: string;
|
|
124
|
+
deviceName?: string;
|
|
125
|
+
/** --no-open 模式:只打印地址,没走完授权 */
|
|
126
|
+
pending?: boolean;
|
|
127
|
+
authorizeUrl?: string;
|
|
128
|
+
}
|
|
129
|
+
/** authLogin 的参数。 */
|
|
130
|
+
export interface AuthLoginOptions {
|
|
131
|
+
serverHttp: string;
|
|
132
|
+
clientId: string;
|
|
133
|
+
deviceName?: string;
|
|
134
|
+
/** 只打印地址不弹浏览器(SSH / 无桌面环境) */
|
|
135
|
+
noOpen?: boolean;
|
|
136
|
+
/** authorize 带 prompt=login(换人登录用) */
|
|
137
|
+
promptLogin?: boolean;
|
|
138
|
+
/** 面板场景:回环监听用面板自己的端口 */
|
|
139
|
+
loopbackPort?: number;
|
|
140
|
+
onUrl?: (url: string) => void;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 起一次授权码流程。
|
|
144
|
+
*
|
|
145
|
+
* 拆成「起流程」与「收回调」两半,是因为**收回调的位置不一样**:
|
|
146
|
+
* · CLI:在**随机端口**另起一个一次性回环监听
|
|
147
|
+
* · 面板:回调落在**面板自己的 HTTP 服务器**上(/callback)
|
|
148
|
+
* 协议部分(PKCE、state、换 token)两边完全一样,所以只写一份。
|
|
149
|
+
*
|
|
150
|
+
* @param cfg 认证中心参数(server 半的 /auth/oidc/config)
|
|
151
|
+
* @param redirectUri 回调地址(必须在白名单内)
|
|
152
|
+
*/
|
|
153
|
+
export declare function beginOauth(cfg: IdpConfig, redirectUri: string, { promptLogin }?: {
|
|
154
|
+
promptLogin?: boolean | undefined;
|
|
155
|
+
}): {
|
|
156
|
+
authorizeUrl: string;
|
|
157
|
+
req: AuthRequest;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* 收回调:用 code + verifier 换 access_token。
|
|
161
|
+
*
|
|
162
|
+
* state 不匹配只有两种可能:回调被串了,或有人在伪造一次登录完成。都必须中止。
|
|
163
|
+
*/
|
|
164
|
+
export declare function finishOauth(cfg: IdpConfig, req: AuthRequest, callback: {
|
|
165
|
+
code: string;
|
|
166
|
+
state: string;
|
|
167
|
+
}): Promise<{
|
|
168
|
+
accessToken: string;
|
|
169
|
+
tokenType?: string;
|
|
170
|
+
expiresIn?: number;
|
|
171
|
+
}>;
|
|
172
|
+
/** 拉取 server 半发布的认证中心参数。 */
|
|
173
|
+
export declare function fetchIdpConfig(serverHttp: string): Promise<IdpConfig>;
|
|
174
|
+
/**
|
|
175
|
+
* 完整登录一次(CLI 用)。
|
|
176
|
+
*
|
|
177
|
+
* @param opts.serverHttp server 半的 HTTP 基址,如 http://1.94.101.189:7777
|
|
178
|
+
* @param opts.clientId 本机稳定 clientId
|
|
179
|
+
*/
|
|
180
|
+
export declare function authLogin(opts: AuthLoginOptions): Promise<LoginResult>;
|
|
181
|
+
/**
|
|
182
|
+
* 用 access_token 换 mn-ws 自己的设备令牌。
|
|
183
|
+
*
|
|
184
|
+
* 这一步是**跨机**的(CLI → 公网机),走的明文 HTTP 时中间人能看到
|
|
185
|
+
* 那枚 access_token —— 它 10 分钟有效,但窗口内被截获就等于账号接管。
|
|
186
|
+
* 所以部署侧必须前置 TLS(见 docs/DEPLOY.md 的「已知风险」)。
|
|
187
|
+
*/
|
|
188
|
+
export declare function verifyExchange({ serverHttp, accessToken, clientId, deviceName, }: {
|
|
189
|
+
serverHttp: string;
|
|
190
|
+
accessToken: string;
|
|
191
|
+
clientId: string;
|
|
192
|
+
deviceName?: string;
|
|
193
|
+
}): Promise<LoginResult>;
|
|
194
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/core/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* client 半的统一登录:PKCE 授权码流程。
|
|
7
|
+
*
|
|
8
|
+
* ## 两种发起方式,同一套协议
|
|
9
|
+
*
|
|
10
|
+
* · **CLI**(`mn-ws auth login`):在**随机端口**起一次性回环监听,
|
|
11
|
+
* 浏览器把授权码送回来,CLI 自己换 token。
|
|
12
|
+
* · **Web 面板**(面板里点「登录」):在**面板自己的端口**上收码
|
|
13
|
+
* (路径固定 /callback),面板后端换 token。
|
|
14
|
+
*
|
|
15
|
+
* 两者用的是同一个 client_id(`mn-ws-client`),回调白名单是
|
|
16
|
+
* `http://127.0.0.1:<任意端口>/callback` —— 端口通配、路径固定,主机与路径都严格受限。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 上面那句「任意端口」在别处写成字面量会出事:端口通配的形态是
|
|
19
|
+
* 「星号紧跟斜杠」,出现在**块注释里**会把注释提前闭合 ——
|
|
20
|
+
* 文件后半截被当成代码,报一堆莫名其妙的语法错。mn-rt 真实踩过一次。
|
|
21
|
+
*
|
|
22
|
+
* ## 为什么 CLI 自己换 token(而不是交给 server 换)
|
|
23
|
+
*
|
|
24
|
+
* 交给 server 换需要 server 自己发起 authorize 并持有 PKCE verifier ——
|
|
25
|
+
* 那就多一份 RP 会话(cookie/存储)要实现。CLI 自己换 token 只需登记
|
|
26
|
+
* 回环通配(127.0.0.1 端口任意、路径固定 /callback)—— RFC 8252 给原生
|
|
27
|
+
* 客户端的标准做法。
|
|
28
|
+
*/
|
|
29
|
+
/** CLI 侧回环回调路径。端口 0 让内核分配,再回填进 authorize 请求。 */
|
|
30
|
+
export const LOOPBACK_PATH = '/callback';
|
|
31
|
+
/** 等用户完成登录的时间上限(5 分钟)。 */
|
|
32
|
+
export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
33
|
+
export function b64url(buf) {
|
|
34
|
+
return Buffer.from(buf).toString('base64')
|
|
35
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 取「发起授权码流程时该用的 client_id」。
|
|
39
|
+
*
|
|
40
|
+
* 两端拆开之后:**server 半有它自己的身份**(mn-ws-server),而真正发起授权码
|
|
41
|
+
* 流程的是 **client 半**(mn-ws-client)。server 半通过 /auth/oidc/config 把
|
|
42
|
+
* 后者一并发布出来,所以新装的 client 半不需要额外配置 —— 与 mn-rt
|
|
43
|
+
* 「server 告诉 client 用哪个 client_id」的做法一致,只是这里明确分成了两个身份。
|
|
44
|
+
*
|
|
45
|
+
* 回落 cfg.clientId:老的 server 只发布一个 client_id,那时的登录用的就是它。
|
|
46
|
+
*/
|
|
47
|
+
export function loginClientIdOf(cfg) {
|
|
48
|
+
return String((cfg && cfg.loginClientId) || (cfg && cfg.clientId) || '').trim();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 生成 PKCE 与一次性参数。
|
|
52
|
+
*
|
|
53
|
+
* 注意 state 只有**一个**值:authorizeParams 里的那个就是真正发出去的,
|
|
54
|
+
* 也是回调回来后要比对的。曾经同时返回两个独立随机值,导致每次登录都必然
|
|
55
|
+
* 报 state 不一致 —— 这里只留一份。
|
|
56
|
+
*/
|
|
57
|
+
export function createAuthRequest(redirectUri) {
|
|
58
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
59
|
+
// PKCE 的 challenge = BASE64URL(SHA256(verifier)),S256
|
|
60
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
61
|
+
const state = b64url(crypto.randomBytes(16));
|
|
62
|
+
return {
|
|
63
|
+
verifier,
|
|
64
|
+
challenge,
|
|
65
|
+
state,
|
|
66
|
+
nonce: b64url(crypto.randomBytes(16)),
|
|
67
|
+
authorizeParams: {
|
|
68
|
+
response_type: 'code',
|
|
69
|
+
redirect_uri: redirectUri,
|
|
70
|
+
code_challenge: challenge,
|
|
71
|
+
code_challenge_method: 'S256',
|
|
72
|
+
state,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** 组 authorize URL。 */
|
|
77
|
+
export function buildAuthorizeUrl(cfg, req, { promptLogin = false } = {}) {
|
|
78
|
+
const authorizeUrl = new URL(cfg.authorizationEndpoint || `${cfg.issuer}/oauth2/auth`);
|
|
79
|
+
authorizeUrl.searchParams.set('client_id', loginClientIdOf(cfg));
|
|
80
|
+
authorizeUrl.searchParams.set('scope', (cfg.scopes || ['openid', 'profile', 'email']).join(' '));
|
|
81
|
+
authorizeUrl.searchParams.set('state', req.state);
|
|
82
|
+
authorizeUrl.searchParams.set('nonce', req.nonce);
|
|
83
|
+
for (const [k, v] of Object.entries(req.authorizeParams))
|
|
84
|
+
authorizeUrl.searchParams.set(k, v);
|
|
85
|
+
if (promptLogin)
|
|
86
|
+
authorizeUrl.searchParams.set('prompt', 'login');
|
|
87
|
+
return authorizeUrl.toString();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 起一次性回环监听,等浏览器把 code 送回来。
|
|
91
|
+
*
|
|
92
|
+
* 只绑 127.0.0.1:绑 0.0.0.0 会让同网段任何人都能来抢这个 code。
|
|
93
|
+
*/
|
|
94
|
+
export function openLoopback({ port = 0 } = {}) {
|
|
95
|
+
let server;
|
|
96
|
+
let resolveFn;
|
|
97
|
+
let rejectFn;
|
|
98
|
+
const got = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
|
|
99
|
+
return {
|
|
100
|
+
start() {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
server = http.createServer((req, res) => {
|
|
103
|
+
const u = new URL(req.url || '/', 'http://127.0.0.1');
|
|
104
|
+
if (u.pathname !== LOOPBACK_PATH) {
|
|
105
|
+
res.writeHead(404).end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const err = u.searchParams.get('error');
|
|
109
|
+
if (err) {
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
111
|
+
.end('<h3>授权未通过</h3><p>可以关闭这个页面了。</p>');
|
|
112
|
+
rejectFn?.(new Error(`授权被拒绝:${err}`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const code = u.searchParams.get('code');
|
|
116
|
+
const state = u.searchParams.get('state');
|
|
117
|
+
if (!code) {
|
|
118
|
+
rejectFn?.(new Error('回调里没有 code'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
122
|
+
.end('<h3>✅ 已收到授权</h3><p>可以关闭浏览器,回到终端了。</p>');
|
|
123
|
+
resolveFn?.({ code, state: state || '' });
|
|
124
|
+
});
|
|
125
|
+
server.once('error', reject);
|
|
126
|
+
server.listen(port, '127.0.0.1', () => {
|
|
127
|
+
const addr = server?.address();
|
|
128
|
+
const actual = typeof addr === 'object' && addr ? addr.port : port;
|
|
129
|
+
resolve(`http://127.0.0.1:${actual}${LOOPBACK_PATH}`);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
wait(ms = LOGIN_TIMEOUT_MS) {
|
|
134
|
+
return Promise.race([
|
|
135
|
+
got,
|
|
136
|
+
new Promise((_, rej) => {
|
|
137
|
+
const t = setTimeout(() => rej(new Error('登录超时(5 分钟未完成)')), ms);
|
|
138
|
+
t.unref?.();
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
},
|
|
142
|
+
close() {
|
|
143
|
+
try {
|
|
144
|
+
server?.close();
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 各平台打开浏览器的命令与参数。
|
|
152
|
+
*
|
|
153
|
+
* ── Windows 为什么绝不借道 `cmd /c start` ────────────────────────────────
|
|
154
|
+
* 授权 URL 里全是 `&`。Node execFile 对不含空格的参数不加引号,命令行到达
|
|
155
|
+
* cmd.exe 后 `&` 被**当成命令分隔符**:浏览器只收到第一个 `&` 之前的部分,
|
|
156
|
+
* redirect_uri / code_challenge 全被切掉,报「redirect_uri 未在白名单内」。
|
|
157
|
+
* rundll32 的 FileProtocolHandler 不经任何 shell 解析,命令行原样到达。
|
|
158
|
+
*/
|
|
159
|
+
export function openBrowserArgs(platform, url) {
|
|
160
|
+
if (platform === 'darwin')
|
|
161
|
+
return ['open', [url]];
|
|
162
|
+
if (platform === 'win32')
|
|
163
|
+
return ['rundll32', ['url.dll,FileProtocolHandler', url]];
|
|
164
|
+
return ['xdg-open', [url]];
|
|
165
|
+
}
|
|
166
|
+
export function openBrowser(url) {
|
|
167
|
+
const [cmd, args] = openBrowserArgs(process.platform, url);
|
|
168
|
+
// spawn + detached + unref:开浏览器**不能**拖住 CLI(execFile 会等它退出)。
|
|
169
|
+
// detached 让它脱离父进程,unref 让它不占着事件循环。
|
|
170
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
171
|
+
child.on('error', (e) => {
|
|
172
|
+
process.stderr.write(`自动打开浏览器失败(${e.message.split('\n')[0]}),请手动打开上面打印的授权地址\n`);
|
|
173
|
+
});
|
|
174
|
+
child.unref();
|
|
175
|
+
}
|
|
176
|
+
export async function getJson(url) {
|
|
177
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
178
|
+
const raw = await res.text();
|
|
179
|
+
let json = null;
|
|
180
|
+
try {
|
|
181
|
+
json = JSON.parse(raw);
|
|
182
|
+
}
|
|
183
|
+
catch { /* ignore */ }
|
|
184
|
+
return { status: res.status, json, raw };
|
|
185
|
+
}
|
|
186
|
+
/** POST 一个 form(application/x-www-form-urlencoded)。 */
|
|
187
|
+
export async function postJson(url, { form }) {
|
|
188
|
+
const body = new URLSearchParams(form).toString();
|
|
189
|
+
const res = await fetch(url, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
192
|
+
body,
|
|
193
|
+
});
|
|
194
|
+
const raw = await res.text();
|
|
195
|
+
let json = null;
|
|
196
|
+
try {
|
|
197
|
+
json = JSON.parse(raw);
|
|
198
|
+
}
|
|
199
|
+
catch { /* ignore */ }
|
|
200
|
+
return { status: res.status, json, raw };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 起一次授权码流程。
|
|
204
|
+
*
|
|
205
|
+
* 拆成「起流程」与「收回调」两半,是因为**收回调的位置不一样**:
|
|
206
|
+
* · CLI:在**随机端口**另起一个一次性回环监听
|
|
207
|
+
* · 面板:回调落在**面板自己的 HTTP 服务器**上(/callback)
|
|
208
|
+
* 协议部分(PKCE、state、换 token)两边完全一样,所以只写一份。
|
|
209
|
+
*
|
|
210
|
+
* @param cfg 认证中心参数(server 半的 /auth/oidc/config)
|
|
211
|
+
* @param redirectUri 回调地址(必须在白名单内)
|
|
212
|
+
*/
|
|
213
|
+
export function beginOauth(cfg, redirectUri, { promptLogin = false } = {}) {
|
|
214
|
+
const req = createAuthRequest(redirectUri);
|
|
215
|
+
const authorizeUrl = buildAuthorizeUrl(cfg, req, { promptLogin });
|
|
216
|
+
return { authorizeUrl, req };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 收回调:用 code + verifier 换 access_token。
|
|
220
|
+
*
|
|
221
|
+
* state 不匹配只有两种可能:回调被串了,或有人在伪造一次登录完成。都必须中止。
|
|
222
|
+
*/
|
|
223
|
+
export async function finishOauth(cfg, req, callback) {
|
|
224
|
+
if (callback.state !== req.state)
|
|
225
|
+
throw new Error('state 不一致,已中止登录');
|
|
226
|
+
const loginClientId = loginClientIdOf(cfg);
|
|
227
|
+
if (!loginClientId)
|
|
228
|
+
throw new Error('服务端未发布登录用的 client_id(loginClientId / clientId)');
|
|
229
|
+
// CLI 自己换 token(公共客户端 + PKCE,无 secret)
|
|
230
|
+
const tokRes = await postJson(cfg.tokenEndpoint || `${cfg.issuer}/oauth2/token`, {
|
|
231
|
+
form: {
|
|
232
|
+
grant_type: 'authorization_code',
|
|
233
|
+
code: callback.code,
|
|
234
|
+
client_id: loginClientId,
|
|
235
|
+
redirect_uri: req.authorizeParams.redirect_uri || '',
|
|
236
|
+
code_verifier: req.verifier,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const tok = tokRes.json || {};
|
|
240
|
+
if (!tokRes.status || tokRes.status >= 400 || !tok.access_token) {
|
|
241
|
+
throw new Error(`换 token 失败:${String(tok.error || tokRes.raw || `HTTP ${tokRes.status}`)}`);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
accessToken: String(tok.access_token),
|
|
245
|
+
tokenType: tok.token_type ? String(tok.token_type) : undefined,
|
|
246
|
+
expiresIn: typeof tok.expires_in === 'number' ? tok.expires_in : undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** 拉取 server 半发布的认证中心参数。 */
|
|
250
|
+
export async function fetchIdpConfig(serverHttp) {
|
|
251
|
+
const base = serverHttp.replace(/\/+$/, '');
|
|
252
|
+
if (!base)
|
|
253
|
+
throw new Error('缺少服务端地址(--url 或配置文件)');
|
|
254
|
+
const cfgRes = await getJson(`${base}/auth/oidc/config`);
|
|
255
|
+
const cfg = (cfgRes.json || {});
|
|
256
|
+
if (!cfg.enabled) {
|
|
257
|
+
throw new Error('服务端未开启统一登录(MN_WS_AUTH_ENABLED)。'
|
|
258
|
+
+ '旧用法仍可用:mn-ws --token <门禁token>');
|
|
259
|
+
}
|
|
260
|
+
if (!cfg.issuer || !cfg.clientId) {
|
|
261
|
+
throw new Error(`服务端统一登录配置不完整:${JSON.stringify(cfg)}`);
|
|
262
|
+
}
|
|
263
|
+
return cfg;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 完整登录一次(CLI 用)。
|
|
267
|
+
*
|
|
268
|
+
* @param opts.serverHttp server 半的 HTTP 基址,如 http://1.94.101.189:7777
|
|
269
|
+
* @param opts.clientId 本机稳定 clientId
|
|
270
|
+
*/
|
|
271
|
+
export async function authLogin(opts) {
|
|
272
|
+
const base = opts.serverHttp.replace(/\/+$/, '');
|
|
273
|
+
if (!base)
|
|
274
|
+
throw new Error('缺少服务端地址(--url 或配置文件)');
|
|
275
|
+
const cfg = await fetchIdpConfig(base);
|
|
276
|
+
const lb = openLoopback({ port: opts.loopbackPort ?? 0 });
|
|
277
|
+
const redirectUri = await lb.start();
|
|
278
|
+
const { authorizeUrl, req } = beginOauth(cfg, redirectUri, { promptLogin: opts.promptLogin === true });
|
|
279
|
+
if (opts.onUrl)
|
|
280
|
+
opts.onUrl(authorizeUrl);
|
|
281
|
+
if (opts.noOpen) {
|
|
282
|
+
lb.close();
|
|
283
|
+
return { pending: true, authorizeUrl };
|
|
284
|
+
}
|
|
285
|
+
openBrowser(authorizeUrl);
|
|
286
|
+
let callback;
|
|
287
|
+
try {
|
|
288
|
+
callback = await lb.wait();
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
lb.close();
|
|
292
|
+
}
|
|
293
|
+
const tok = await finishOauth(cfg, req, callback);
|
|
294
|
+
// ⑤ 换 mn-ws 自己的设备令牌
|
|
295
|
+
const res = await verifyExchange({
|
|
296
|
+
serverHttp: base,
|
|
297
|
+
accessToken: tok.accessToken,
|
|
298
|
+
clientId: opts.clientId,
|
|
299
|
+
deviceName: opts.deviceName,
|
|
300
|
+
});
|
|
301
|
+
return { ...res, clientId: res.clientId || opts.clientId };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* 用 access_token 换 mn-ws 自己的设备令牌。
|
|
305
|
+
*
|
|
306
|
+
* 这一步是**跨机**的(CLI → 公网机),走的明文 HTTP 时中间人能看到
|
|
307
|
+
* 那枚 access_token —— 它 10 分钟有效,但窗口内被截获就等于账号接管。
|
|
308
|
+
* 所以部署侧必须前置 TLS(见 docs/DEPLOY.md 的「已知风险」)。
|
|
309
|
+
*/
|
|
310
|
+
export async function verifyExchange({ serverHttp, accessToken, clientId, deviceName, }) {
|
|
311
|
+
const res = await fetch(`${serverHttp.replace(/\/+$/, '')}/auth/verify`, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: {
|
|
314
|
+
authorization: `Bearer ${accessToken}`,
|
|
315
|
+
'content-type': 'application/json',
|
|
316
|
+
accept: 'application/json',
|
|
317
|
+
},
|
|
318
|
+
body: JSON.stringify({ clientId, deviceName: deviceName || '' }),
|
|
319
|
+
});
|
|
320
|
+
const raw = await res.text();
|
|
321
|
+
let json = null;
|
|
322
|
+
try {
|
|
323
|
+
json = JSON.parse(raw);
|
|
324
|
+
}
|
|
325
|
+
catch { /* ignore */ }
|
|
326
|
+
if (!res.ok || !json || json.ok !== true) {
|
|
327
|
+
throw new Error(`换设备令牌失败:${String((json && json.error) || raw || `HTTP ${res.status}`)}`);
|
|
328
|
+
}
|
|
329
|
+
return json;
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=auth.js.map
|