creditdaddy 1.1.1 → 1.3.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/src/authDevice.js CHANGED
@@ -1,205 +1,208 @@
1
- /**
2
- * 浏览器登录(统一入口):面板发起 → 浏览器 / 隐私窗口里授权 → 面板轮询 → 自动入库。
3
- *
4
- * qoder / qoder-cn Qoder 设备码登录(PKCE + nonce,与官方 qodercli 同流程,移植自 10router)
5
- * workbuddy / workbuddy-intl WorkBuddy / CodeBuddy 登录(state 轮询,见 workbuddyAuth.js)
6
- * zcode-bigmodel / zcode-zai ZCode 登录(客户端 CLI 轮询流程,见 zcodeAuth.js)
7
- *
8
- * Qoder 流程:
9
- * 1. 本地生成 PKCE 对 (verifier/challenge-S256) + nonce + machine_id
10
- * 2. 打开 verificationUriComplete(qoder.com/device/selectAccounts?challenge=...)选择账号授权
11
- * 3. 带 nonce+verifier 轮询 openapi.qoder.sh/api/v1/deviceToken/poll,202/404=等待,200=拿到 dt- token
12
- * token 约 30 天有效;上游 refresh 对该流程 403,过期后重新走一遍登录即可。
13
- *
14
- * 会话只存在内存里(授权是短时动作,重启重来即可);面板每 2s 轮询一次,
15
- * 各流程按自己的上游间隔节流,未到时间直接回 pending。
16
- */
17
-
18
- import crypto from 'node:crypto';
19
- import {
20
- OPENAPI_BASE, CN_OPENAPI_BASE,
21
- LOGIN_URL, CN_LOGIN_URL,
22
- USERINFO_PATH, PROVIDER_LABEL,
23
- } from './constants.js';
24
- import { publicAccount } from './store.js';
25
- import { addAccount } from './accounts.js';
26
- import { logger } from './logger.js';
27
- import { startWorkbuddyLogin, pollWorkbuddyLogin } from './workbuddyAuth.js';
28
- import { startZcodeLogin, pollZcodeLogin } from './zcodeAuth.js';
29
-
30
- const FETCH_TIMEOUT_MS = 15000;
31
- const QODER_TTL_MS = 5 * 60 * 1000; // Qoder 授权链接 5 分钟有效
32
- const QODER_POLL_MS = 2000;
33
-
34
- const sessions = new Map();
35
-
36
- function base64Url(buf) {
37
- return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
38
- }
39
-
40
- async function fetchWithTimeout(url, init = {}) {
41
- const controller = new AbortController();
42
- const timer = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS);
43
- try {
44
- return await fetch(url, { ...init, signal: controller.signal });
45
- } finally {
46
- clearTimeout(timer);
47
- }
48
- }
49
-
50
- // ── Qoder ──
51
- export function endpointsFor(provider) {
52
- const cn = provider === 'qoder-cn';
53
- return {
54
- deviceTokenUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + '/api/v1/deviceToken/poll',
55
- loginUrl: cn ? CN_LOGIN_URL : LOGIN_URL,
56
- userInfoUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + USERINFO_PATH,
57
- };
58
- }
59
-
60
- function startQoderLogin(provider) {
61
- const ep = endpointsFor(provider);
62
- const verifier = base64Url(crypto.randomBytes(32));
63
- const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
64
- const nonce = crypto.randomUUID();
65
- const params = new URLSearchParams({ challenge, challenge_method: 'S256', machine_id: crypto.randomUUID(), nonce });
66
- return {
67
- url: ep.loginUrl + '?' + params.toString(),
68
- expiresInMs: QODER_TTL_MS,
69
- intervalMs: QODER_POLL_MS,
70
- data: { provider, nonce, verifier },
71
- };
72
- }
73
-
74
- /** 用户资料(best-effort,失败不阻断登录) */
75
- async function fetchQoderProfile(provider, accessToken) {
76
- try {
77
- const res = await fetchWithTimeout(endpointsFor(provider).userInfoUrl, {
78
- headers: { Authorization: 'Bearer ' + accessToken, Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' },
79
- });
80
- if (!res.ok) return { name: null, email: null };
81
- const b = await res.json();
82
- return { name: (b.name || b.username || '').trim() || null, email: (b.email || '').trim() || null };
83
- } catch { return { name: null, email: null }; }
84
- }
85
-
86
- async function pollQoderLogin(data) {
87
- const url = endpointsFor(data.provider).deviceTokenUrl
88
- + '?nonce=' + encodeURIComponent(data.nonce)
89
- + '&verifier=' + encodeURIComponent(data.verifier)
90
- + '&challenge_method=S256';
91
- const res = await fetchWithTimeout(url, { headers: { Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' } });
92
- if (res.status === 202 || res.status === 404) return { status: 'pending' };
93
-
94
- const text = await res.text();
95
- if (!res.ok) {
96
- let msg = '轮询失败 HTTP ' + res.status;
97
- try { const b = JSON.parse(text); if (b.message) msg += ':' + b.message; } catch {}
98
- throw new Error(msg);
99
- }
100
- let body;
101
- try { body = JSON.parse(text); } catch (e) { throw new Error('轮询返回非 JSON:' + e.message); }
102
- if (!body.token) throw new Error('轮询返回 200 但没有 token(上游结构可能变化)');
103
-
104
- const profile = await fetchQoderProfile(data.provider, body.token);
105
- return {
106
- status: 'ok',
107
- input: {
108
- provider: data.provider,
109
- token: body.token,
110
- name: profile.name || profile.email || null,
111
- email: profile.email,
112
- uid: body.user_id || null,
113
- refreshToken: body.refresh_token || body.refreshToken || null,
114
- expiresAt: body.expires_at || (body.expires_in ? Date.now() + Number(body.expires_in) * 1000 : null),
115
- source: 'device',
116
- },
117
- };
118
- }
119
-
120
- // ── 统一入口 ──
121
- const FLOWS = {
122
- qoder: { start: startQoderLogin, poll: pollQoderLogin },
123
- 'qoder-cn': { start: startQoderLogin, poll: pollQoderLogin },
124
- workbuddy: { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
125
- 'workbuddy-intl': { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
126
- 'zcode-bigmodel': { start: startZcodeLogin, poll: pollZcodeLogin },
127
- 'zcode-zai': { start: startZcodeLogin, poll: pollZcodeLogin },
128
- };
129
- export const LOGIN_KINDS = Object.keys(FLOWS);
130
- const KIND_LABEL = { 'zcode-bigmodel': 'ZCode(BigModel)', 'zcode-zai': 'ZCode(Z.ai)' };
131
- const labelOf = (kind) => KIND_LABEL[kind] || PROVIDER_LABEL[kind] || kind;
132
-
133
- function gcSessions() {
134
- const now = Date.now();
135
- for (const [k, s] of sessions) if (s.expiresAt < now || s.done) sessions.delete(k);
136
- }
137
-
138
- /** 创建一次登录会话,返回浏览器授权地址 */
139
- export async function startDeviceFlow(kind) {
140
- const flow = FLOWS[kind];
141
- if (!flow) throw new Error('不支持的登录类型:' + kind);
142
- gcSessions();
143
- const r = await flow.start(kind);
144
- const sessionId = crypto.randomUUID();
145
- sessions.set(sessionId, {
146
- kind, data: r.data,
147
- expiresAt: Date.now() + r.expiresInMs,
148
- intervalMs: r.intervalMs, nextPollAt: 0,
149
- done: false, busy: false,
150
- });
151
- logger.info('DEVICE', '发起浏览器登录(' + labelOf(kind) + ')');
152
- return {
153
- sessionId,
154
- url: r.url,
155
- expiresIn: Math.floor(r.expiresInMs / 1000),
156
- interval: Math.max(1, Math.floor(r.intervalMs / 1000)),
157
- };
158
- }
159
-
160
- /**
161
- * 面板侧轮询入口。返回:
162
- * { status: 'pending' } 继续等
163
- * { status: 'ok', account, already } 成功(already=账号已存在)
164
- * { status: 'expired' } 会话过期,重新发起
165
- * { status: 'failed', error } 终态失败
166
- */
167
- export async function pollDeviceFlow(sessionId) {
168
- gcSessions();
169
- const session = sessions.get(sessionId);
170
- if (!session) return { status: 'expired' };
171
- if (Date.now() > session.expiresAt) {
172
- sessions.delete(sessionId);
173
- return { status: 'expired' };
174
- }
175
- // 节流:未到上游轮询间隔、或上一轮还在进行,直接回 pending
176
- if (session.busy || Date.now() < session.nextPollAt) return { status: 'pending' };
177
- session.busy = true;
178
- session.nextPollAt = Date.now() + session.intervalMs;
179
-
180
- let result;
181
- try {
182
- result = await FLOWS[session.kind].poll(session.data);
183
- } catch (err) {
184
- sessions.delete(sessionId);
185
- logger.warn('DEVICE', labelOf(session.kind) + ' 登录失败:' + err.message);
186
- return { status: 'failed', error: err.message };
187
- } finally {
188
- session.busy = false;
189
- }
190
- if (result.status === 'pending') return { status: 'pending' };
191
-
192
- let added;
193
- try {
194
- added = await addAccount(result.input, { trusted: true });
195
- } catch (e) {
196
- sessions.delete(sessionId);
197
- return { status: 'failed', error: e.message };
198
- }
199
- session.done = true;
200
- const { account, duplicate } = added;
201
- logger.info('DEVICE', duplicate
202
- ? labelOf(session.kind) + ' 登录成功:账号已存在,已更新凭据(' + (account.name || account.id) + ')'
203
- : labelOf(session.kind) + ' 登录成功:' + (account.name || account.id));
204
- return { status: 'ok', account: publicAccount(account), already: duplicate };
205
- }
1
+ /**
2
+ * 浏览器登录(统一入口):面板发起 → 浏览器 / 隐私窗口里授权 → 面板轮询 → 自动入库。
3
+ *
4
+ * qoder / qoder-cn Qoder 设备码登录(PKCE + nonce,与官方 qodercli 同流程,移植自 10router)
5
+ * workbuddy / workbuddy-intl WorkBuddy / CodeBuddy 登录(state 轮询,见 workbuddyAuth.js)
6
+ * zcode-bigmodel / zcode-zai ZCode 登录(客户端 CLI 轮询流程,见 zcodeAuth.js)
7
+ * minimax MiniMax Code 登录(OAuth 设备码 + S256 PKCE,见 minimaxAuth.js)
8
+ *
9
+ * Qoder 流程:
10
+ * 1. 本地生成 PKCE 对 (verifier/challenge-S256) + nonce + machine_id
11
+ * 2. 打开 verificationUriComplete(qoder.com/device/selectAccounts?challenge=...)选择账号授权
12
+ * 3. 带 nonce+verifier 轮询 openapi.qoder.sh/api/v1/deviceToken/poll,202/404=等待,200=拿到 dt- token
13
+ * token 约 30 天有效;上游 refresh 对该流程 403,过期后重新走一遍登录即可。
14
+ *
15
+ * 会话只存在内存里(授权是短时动作,重启重来即可);面板每 2s 轮询一次,
16
+ * 各流程按自己的上游间隔节流,未到时间直接回 pending。
17
+ */
18
+
19
+ import crypto from 'node:crypto';
20
+ import {
21
+ OPENAPI_BASE, CN_OPENAPI_BASE,
22
+ LOGIN_URL, CN_LOGIN_URL,
23
+ USERINFO_PATH, PROVIDER_LABEL,
24
+ } from './constants.js';
25
+ import { publicAccount } from './store.js';
26
+ import { addAccount } from './accounts.js';
27
+ import { logger } from './logger.js';
28
+ import { startWorkbuddyLogin, pollWorkbuddyLogin } from './workbuddyAuth.js';
29
+ import { startZcodeLogin, pollZcodeLogin } from './zcodeAuth.js';
30
+ import { startMiniMaxLogin, pollMiniMaxLogin } from './minimaxAuth.js';
31
+
32
+ const FETCH_TIMEOUT_MS = 15000;
33
+ const QODER_TTL_MS = 5 * 60 * 1000; // Qoder 授权链接 5 分钟有效
34
+ const QODER_POLL_MS = 2000;
35
+
36
+ const sessions = new Map();
37
+
38
+ function base64Url(buf) {
39
+ return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
40
+ }
41
+
42
+ async function fetchWithTimeout(url, init = {}) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS);
45
+ try {
46
+ return await fetch(url, { ...init, signal: controller.signal });
47
+ } finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+
52
+ // ── Qoder ──
53
+ export function endpointsFor(provider) {
54
+ const cn = provider === 'qoder-cn';
55
+ return {
56
+ deviceTokenUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + '/api/v1/deviceToken/poll',
57
+ loginUrl: cn ? CN_LOGIN_URL : LOGIN_URL,
58
+ userInfoUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + USERINFO_PATH,
59
+ };
60
+ }
61
+
62
+ function startQoderLogin(provider) {
63
+ const ep = endpointsFor(provider);
64
+ const verifier = base64Url(crypto.randomBytes(32));
65
+ const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
66
+ const nonce = crypto.randomUUID();
67
+ const params = new URLSearchParams({ challenge, challenge_method: 'S256', machine_id: crypto.randomUUID(), nonce });
68
+ return {
69
+ url: ep.loginUrl + '?' + params.toString(),
70
+ expiresInMs: QODER_TTL_MS,
71
+ intervalMs: QODER_POLL_MS,
72
+ data: { provider, nonce, verifier },
73
+ };
74
+ }
75
+
76
+ /** 用户资料(best-effort,失败不阻断登录) */
77
+ async function fetchQoderProfile(provider, accessToken) {
78
+ try {
79
+ const res = await fetchWithTimeout(endpointsFor(provider).userInfoUrl, {
80
+ headers: { Authorization: 'Bearer ' + accessToken, Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' },
81
+ });
82
+ if (!res.ok) return { name: null, email: null };
83
+ const b = await res.json();
84
+ return { name: (b.name || b.username || '').trim() || null, email: (b.email || '').trim() || null };
85
+ } catch { return { name: null, email: null }; }
86
+ }
87
+
88
+ async function pollQoderLogin(data) {
89
+ const url = endpointsFor(data.provider).deviceTokenUrl
90
+ + '?nonce=' + encodeURIComponent(data.nonce)
91
+ + '&verifier=' + encodeURIComponent(data.verifier)
92
+ + '&challenge_method=S256';
93
+ const res = await fetchWithTimeout(url, { headers: { Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' } });
94
+ if (res.status === 202 || res.status === 404) return { status: 'pending' };
95
+
96
+ const text = await res.text();
97
+ if (!res.ok) {
98
+ let msg = '轮询失败 HTTP ' + res.status;
99
+ try { const b = JSON.parse(text); if (b.message) msg += ':' + b.message; } catch {}
100
+ throw new Error(msg);
101
+ }
102
+ let body;
103
+ try { body = JSON.parse(text); } catch (e) { throw new Error('轮询返回非 JSON:' + e.message); }
104
+ if (!body.token) throw new Error('轮询返回 200 但没有 token(上游结构可能变化)');
105
+
106
+ const profile = await fetchQoderProfile(data.provider, body.token);
107
+ return {
108
+ status: 'ok',
109
+ input: {
110
+ provider: data.provider,
111
+ token: body.token,
112
+ name: profile.name || profile.email || null,
113
+ email: profile.email,
114
+ uid: body.user_id || null,
115
+ refreshToken: body.refresh_token || body.refreshToken || null,
116
+ expiresAt: body.expires_at || (body.expires_in ? Date.now() + Number(body.expires_in) * 1000 : null),
117
+ source: 'device',
118
+ },
119
+ };
120
+ }
121
+
122
+ // ── 统一入口 ──
123
+ const FLOWS = {
124
+ qoder: { start: startQoderLogin, poll: pollQoderLogin },
125
+ 'qoder-cn': { start: startQoderLogin, poll: pollQoderLogin },
126
+ workbuddy: { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
127
+ 'workbuddy-intl': { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
128
+ 'zcode-bigmodel': { start: startZcodeLogin, poll: pollZcodeLogin },
129
+ 'zcode-zai': { start: startZcodeLogin, poll: pollZcodeLogin },
130
+ minimax: { start: startMiniMaxLogin, poll: pollMiniMaxLogin },
131
+ };
132
+ export const LOGIN_KINDS = Object.keys(FLOWS);
133
+ const KIND_LABEL = { 'zcode-bigmodel': 'ZCode(BigModel)', 'zcode-zai': 'ZCode(Z.ai)' };
134
+ const labelOf = (kind) => KIND_LABEL[kind] || PROVIDER_LABEL[kind] || kind;
135
+
136
+ function gcSessions() {
137
+ const now = Date.now();
138
+ for (const [k, s] of sessions) if (s.expiresAt < now || s.done) sessions.delete(k);
139
+ }
140
+
141
+ /** 创建一次登录会话,返回浏览器授权地址 */
142
+ export async function startDeviceFlow(kind) {
143
+ const flow = FLOWS[kind];
144
+ if (!flow) throw new Error('不支持的登录类型:' + kind);
145
+ gcSessions();
146
+ const r = await flow.start(kind);
147
+ const sessionId = crypto.randomUUID();
148
+ sessions.set(sessionId, {
149
+ kind, data: r.data,
150
+ expiresAt: Date.now() + r.expiresInMs,
151
+ intervalMs: r.intervalMs, nextPollAt: 0,
152
+ done: false, busy: false,
153
+ });
154
+ logger.info('DEVICE', '发起浏览器登录(' + labelOf(kind) + ')');
155
+ return {
156
+ sessionId,
157
+ url: r.url,
158
+ expiresIn: Math.floor(r.expiresInMs / 1000),
159
+ interval: Math.max(1, Math.floor(r.intervalMs / 1000)),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * 面板侧轮询入口。返回:
165
+ * { status: 'pending' } 继续等
166
+ * { status: 'ok', account, already } 成功(already=账号已存在)
167
+ * { status: 'expired' } 会话过期,重新发起
168
+ * { status: 'failed', error } 终态失败
169
+ */
170
+ export async function pollDeviceFlow(sessionId) {
171
+ gcSessions();
172
+ const session = sessions.get(sessionId);
173
+ if (!session) return { status: 'expired' };
174
+ if (Date.now() > session.expiresAt) {
175
+ sessions.delete(sessionId);
176
+ return { status: 'expired' };
177
+ }
178
+ // 节流:未到上游轮询间隔、或上一轮还在进行,直接回 pending
179
+ if (session.busy || Date.now() < session.nextPollAt) return { status: 'pending' };
180
+ session.busy = true;
181
+ session.nextPollAt = Date.now() + session.intervalMs;
182
+
183
+ let result;
184
+ try {
185
+ result = await FLOWS[session.kind].poll(session.data);
186
+ } catch (err) {
187
+ sessions.delete(sessionId);
188
+ logger.warn('DEVICE', labelOf(session.kind) + ' 登录失败:' + err.message);
189
+ return { status: 'failed', error: err.message };
190
+ } finally {
191
+ session.busy = false;
192
+ }
193
+ if (result.status === 'pending') return { status: 'pending' };
194
+
195
+ let added;
196
+ try {
197
+ added = await addAccount(result.input, { trusted: true });
198
+ } catch (e) {
199
+ sessions.delete(sessionId);
200
+ return { status: 'failed', error: e.message };
201
+ }
202
+ session.done = true;
203
+ const { account, duplicate } = added;
204
+ logger.info('DEVICE', duplicate
205
+ ? labelOf(session.kind) + ' 登录成功:账号已存在,已更新凭据(' + (account.name || account.id) + ')'
206
+ : labelOf(session.kind) + ' 登录成功:' + (account.name || account.id));
207
+ return { status: 'ok', account: publicAccount(account), already: duplicate };
208
+ }