creditdaddy 0.9.5 → 1.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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * CreditDaddy 账号 → 10Router 连接同步:把本机管理的订阅账号推给 10Router 当上游连接。
3
+ *
4
+ * 通道一(推荐):POST /api/oauth/transfer/import —— 需在 10Router 设置里配置「面板密码」。
5
+ * 完整 OAuth 形态(accessToken + refreshToken),10R 端按 JWT sub 去重并原位更新,反复同步幂等;
6
+ * 先用面板密码登录(/api/auth/login)换 auth_token 会话再推送——transfer 路由只认会话 / CLI token。
7
+ * 通道二(默认):POST /api/providers —— 仅用已配的 sk- 虚拟 key。
8
+ * dashboardGuard 为远程 agent 留的口子(POST /api/providers 认虚拟 key);apikey 形态、
9
+ * 不带 refreshToken,token 过期后需重新同步;同名连接返回 409 且不覆盖。
10
+ *
11
+ * 扩展新产品:在 TENROUTER_PROVIDER_MAP 加一行即可(如 zcode → glm 等反代就绪后补上),
12
+ * 同步主流程只读这张表,无需改动其他代码。
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import { buildExportPayload, sealTransfer } from './transfer.js';
17
+
18
+ /** CreditDaddy provider → 10Router 连接类型。留空 / 缺失 = 该产品暂不同步。 */
19
+ export const TENROUTER_PROVIDER_MAP = {
20
+ qoder: 'qoder',
21
+ 'qoder-cn': 'qoder-cn',
22
+ workbuddy: 'codebuddy-cn',
23
+ 'workbuddy-intl': 'codebuddy-intl',
24
+ // zcode → glm:等 zcode 反代就绪后补一行(同步流程已就绪,无需改代码)
25
+ };
26
+
27
+ export const mapProviderId = (provider) => TENROUTER_PROVIDER_MAP[provider] || null;
28
+
29
+ const TIMEOUT_MS = 60_000;
30
+
31
+ async function trFetch(url, { key, cookie, method = 'GET', body } = {}) {
32
+ let res;
33
+ try {
34
+ res = await fetch(url, {
35
+ method,
36
+ headers: {
37
+ Accept: 'application/json',
38
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
39
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
40
+ ...(cookie ? { Cookie: cookie } : {}),
41
+ },
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ signal: AbortSignal.timeout(TIMEOUT_MS),
44
+ });
45
+ } catch (e) {
46
+ throw new Error(`无法连接 10Router:${e.cause?.code || e.message}`);
47
+ }
48
+ const text = await res.text();
49
+ let data = null;
50
+ try { data = text ? JSON.parse(text) : null; } catch {}
51
+ return { status: res.status, data, setCookie: res.headers.get('set-cookie') || '' };
52
+ }
53
+
54
+ const authCookie = (setCookie) => {
55
+ const m = /(?:^|,\s*)auth_token=([^;,\s]+)/.exec(setCookie || '');
56
+ return m ? `auth_token=${m[1]}` : null;
57
+ };
58
+
59
+ /** 用面板密码登录换会话 Cookie;失败抛「面板密码不正确」类错误 */
60
+ async function login(endpoint, password) {
61
+ const r = await trFetch(`${endpoint}/api/auth/login`, { method: 'POST', body: { password } });
62
+ const cookie = authCookie(r.setCookie);
63
+ if (r.status !== 200 || !cookie) {
64
+ throw Object.assign(new Error(r.status === 401 ? '10Router 面板密码不正确' : `10Router 登录失败(HTTP ${r.status})`), { code: 'BAD_PASSWORD' });
65
+ }
66
+ return cookie;
67
+ }
68
+
69
+ const connName = (a) => a.name || a.email || a.uid || 'account-' + a.id;
70
+
71
+ /** 通道一:transfer/import(OAuth 形态,按目标 provider 分组推送) */
72
+ async function syncViaTransfer({ endpoint, cookie, groups }) {
73
+ const results = [];
74
+ for (const [target, list] of groups) {
75
+ const passphrase = crypto.randomBytes(18).toString('base64url'); // 仅作传输口令,不落盘
76
+ const blob = sealTransfer(buildExportPayload(list), passphrase);
77
+ const r = await trFetch(`${endpoint}/api/oauth/transfer/import`, {
78
+ cookie, method: 'POST', body: { provider: target, passphrase, blob },
79
+ });
80
+ if (r.status !== 200) {
81
+ results.push({ provider: target, count: list.length, status: 'failed', error: r.data?.error || `HTTP ${r.status}` });
82
+ continue;
83
+ }
84
+ const d = r.data || {};
85
+ results.push({
86
+ provider: target, count: list.length, status: (d.failed || 0) ? 'partial' : 'ok',
87
+ imported: d.imported || 0, updated: d.updated || 0, skipped: d.skipped || 0, failed: d.failed || 0,
88
+ });
89
+ }
90
+ return results;
91
+ }
92
+
93
+ /** 通道二:POST /api/providers(apikey 形态,逐账号建连接;同名 409 记「已存在」) */
94
+ async function syncViaProviders({ endpoint, key, groups }) {
95
+ const results = [];
96
+ for (const [target, list] of groups) {
97
+ const row = { provider: target, count: list.length, status: 'ok', imported: 0, skipped: 0, failed: 0 };
98
+ for (const a of list) {
99
+ const r = await trFetch(`${endpoint}/api/providers`, {
100
+ key, method: 'POST', body: { provider: target, apiKey: a.token, name: connName(a) },
101
+ });
102
+ if (r.status === 201 || r.status === 200) row.imported++;
103
+ else if (r.status === 409) row.skipped++;
104
+ else { row.failed++; row.error = r.data?.error || `HTTP ${r.status}`; }
105
+ }
106
+ row.status = row.failed ? (row.imported ? 'partial' : 'failed') : 'ok';
107
+ results.push(row);
108
+ }
109
+ return results;
110
+ }
111
+
112
+ /**
113
+ * 同步账号到 10Router。
114
+ * @returns {{ channel: 'oauth'|'apikey', results: Array, summary: string }}
115
+ */
116
+ export async function syncAccountsTo10r({ endpoint, key, adminPassword, accounts }) {
117
+ if (!endpoint) throw Object.assign(new Error('尚未配置 10Router 地址'), { code: 'NOT_CONFIGURED' });
118
+ if (!key && !adminPassword) throw Object.assign(new Error('尚未配置 10Router 虚拟 key'), { code: 'NOT_CONFIGURED' });
119
+
120
+ // 按目标 10R 连接类型分组;无映射的产品(zcode/mirasim/catpaw 等)记入跳过原因
121
+ const groups = new Map();
122
+ const unmapped = new Map();
123
+ for (const a of accounts || []) {
124
+ if (!a || !a.token) continue;
125
+ const target = mapProviderId(a.provider);
126
+ if (!target) {
127
+ if (a.provider) unmapped.set(a.provider, (unmapped.get(a.provider) || 0) + 1);
128
+ continue;
129
+ }
130
+ if (!groups.has(target)) groups.set(target, []);
131
+ groups.get(target).push(a);
132
+ }
133
+ if (!groups.size) throw new Error('没有可同步的账号(qoder / WorkBuddy 系列支持;zcode 待反代就绪后支持)');
134
+
135
+ let channel = 'apikey';
136
+ let cookie = null;
137
+ const results = [];
138
+ if (adminPassword) {
139
+ channel = 'oauth';
140
+ cookie = await login(endpoint, adminPassword);
141
+ try {
142
+ results.push(...await syncViaTransfer({ endpoint, cookie, groups }));
143
+ } finally {
144
+ trFetch(`${endpoint}/api/auth/logout`, { cookie, method: 'POST' }).catch(() => {});
145
+ }
146
+ } else {
147
+ if (!key) throw Object.assign(new Error('尚未配置 10Router 虚拟 key'), { code: 'NOT_CONFIGURED' });
148
+ results.push(...await syncViaProviders({ endpoint, key, groups }));
149
+ }
150
+
151
+ const sum = (k) => results.reduce((n, r) => n + (r[k] || 0), 0);
152
+ const failed = results.filter((r) => r.status === 'failed' || r.status === 'partial');
153
+ const parts = [];
154
+ if (sum('imported')) parts.push(`导入 ${sum('imported')}`);
155
+ if (sum('updated')) parts.push(`更新 ${sum('updated')}`);
156
+ if (sum('skipped')) parts.push(`已存在 ${sum('skipped')}`);
157
+ if (sum('failed')) parts.push(`失败 ${sum('failed')}`);
158
+ if (unmapped.size) parts.push('跳过 ' + [...unmapped].map(([p, n]) => `${p} ×${n}`).join('、'));
159
+ const summary = (channel === 'oauth' ? '账号同步完成(OAuth 通道):' : '账号同步完成(apikey 通道,token 过期后需重新同步):')
160
+ + (parts.join(',') || '无变化')
161
+ + (failed.length ? `;${failed.map((r) => r.provider + ':' + (r.error || r.status)).join(';')}` : '');
162
+ return { channel, results, summary };
163
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Trae CN 服务端 API — 额度查询与每日签到领积分。
3
+ *
4
+ * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/status {} 是否已签 / 可领额度
5
+ * POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim {} 领取(约 150 通用积分/天)
6
+ * POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage {"require_usage":true,"req_source":2}
7
+ *
8
+ * 鉴权:authorization: Cloud-IDE-JWT <jwt>(本机客户端 storage.json 解出的那个)。
9
+ * 这些接口是 IDE 自己在调的,因此必须带全套 IDE 请求头;风控要求 x-device-id 为纯数字设备号,
10
+ * 传 GUID 会返回 code 9074。响应统一信封 { code, message, ... },code !== 0 即业务失败。
11
+ *
12
+ * 请求头集与错误码口径来自社区逆向(TraeWorkAssistant trae_checkin.rs / star620 TraeApiClient.cs 实测)。
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import { fetchJsonRace } from './zcodeClient.js';
17
+ import { FETCH_TIMEOUT_MS } from './constants.js';
18
+ import { uidFromJwt } from './traeLocal.js';
19
+
20
+ export const TRAE_API = 'https://api.trae.cn';
21
+
22
+ const STATUS_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/status`;
23
+ const CLAIM_URL = `${TRAE_API}/trae/api/v2/ug/checkin_credits/claim`;
24
+ const USAGE_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_ent_usage`;
25
+ const PAY_STATUS_URL = `${TRAE_API}/trae/api/v2/pay/ide_user_pay_status`;
26
+
27
+ /** 时间戳宽容转换:Trae 部分字段是秒、部分是毫秒 */
28
+ const iso = (v) => {
29
+ const n = Number(v);
30
+ if (!Number.isFinite(n) || n <= 0) return null;
31
+ const ms = n < 1e11 ? n * 1000 : n;
32
+ const d = new Date(ms);
33
+ return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
34
+ };
35
+
36
+ const hex = (bytes) => crypto.randomBytes(bytes).toString('hex');
37
+
38
+ /**
39
+ * x-market-user-id / vscode-sessionid 不在客户端 storage.json 里,按 uid 确定性派生:
40
+ * 同一账号每次请求指纹恒定(随机值会让服务端看到「每台请求换一台设备」)。
41
+ */
42
+ function seeded(uid, salt, n) {
43
+ const prefix = `${salt}:${uid}`;
44
+ const out = [];
45
+ for (let counter = 0; out.length < n; counter++) {
46
+ out.push(...crypto.createHash('sha256').update(`${prefix}:${counter}`).digest());
47
+ }
48
+ return out.slice(0, n);
49
+ }
50
+
51
+ export function traeDevice(uid, storedDeviceId) {
52
+ const d = seeded(uid, 'devid', 15).map((b) => String(48 + (b % 10)));
53
+ const m = seeded(uid, 'market', 16);
54
+ m[6] = (m[6] & 0x0f) | 0x40;
55
+ m[8] = (m[8] & 0x3f) | 0x80;
56
+ const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
57
+ return {
58
+ // 优先用客户端自报的真实设备号,缺失时回落 uid 派生值
59
+ deviceId: /^\d{8,}$/.test(String(storedDeviceId || '')) ? String(storedDeviceId) : d.join(''),
60
+ marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
61
+ sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
62
+ };
63
+ }
64
+
65
+ function traeHeaders(jwt, dev) {
66
+ const token = jwt.startsWith('Cloud-IDE-JWT ') ? jwt : `Cloud-IDE-JWT ${jwt.trim()}`;
67
+ return {
68
+ accept: '*/*',
69
+ 'accept-language': 'zh-CN',
70
+ authorization: token,
71
+ 'content-type': 'application/json',
72
+ 'user-agent': 'VSCode 1.107.1 (TRAE SOLO CN)',
73
+ 'x-market-client-id': 'VSCode 1.107.1',
74
+ 'x-market-user-id': dev.marketUserId,
75
+ 'x-user-region': 'CN',
76
+ 'x-device-id': dev.deviceId,
77
+ 'x-lgw-req-sdk-type': '3',
78
+ 'package-type': 'stable_cn',
79
+ 'x-request-id': hex(16),
80
+ 'x-lscbd-aid': '787976',
81
+ 'x-lscbd-platform': process.platform === 'win32' ? 'windows' : process.platform,
82
+ 'app-version': '0.1.45',
83
+ 'x-tt-trace-id': `00-${hex(8)}-01`,
84
+ 'vscode-sessionid': dev.sessionId,
85
+ };
86
+ }
87
+
88
+ /** checked_in / credits 可能被包在 data / result 信封里,只读顶层会把已签账号判成未签而重复领取 */
89
+ function findField(obj, key, depth = 0) {
90
+ if (!obj || typeof obj !== 'object' || depth > 2) return undefined;
91
+ if (key in obj) return obj[key];
92
+ for (const child of [obj.data, obj.result, obj.Result]) {
93
+ const v = findField(child, key, depth + 1);
94
+ if (v !== undefined) return v;
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ /** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效) */
100
+ async function post(url, account, body = {}) {
101
+ const jwt = account.token;
102
+ if (!jwt) throw new Error('账号缺少 Trae 登录 token(请到 Trae 客户端登录后用「本机导入」同步)');
103
+ const uid = account.uid || uidFromJwt(jwt);
104
+ const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId);
105
+ const res = await fetchJsonRace(url, {
106
+ method: 'POST',
107
+ headers: traeHeaders(jwt, dev),
108
+ body: JSON.stringify(body),
109
+ timeoutMs: FETCH_TIMEOUT_MS,
110
+ });
111
+ if (res.status === 401) {
112
+ const e = new Error('Trae 登录凭据已失效,请在 Trae 客户端重新登录后重新本机导入');
113
+ e.auth = true;
114
+ throw e;
115
+ }
116
+ const parsed = await res.json().catch(() => null);
117
+ if (!parsed || typeof parsed !== 'object') {
118
+ throw new Error(`Trae 接口返回无法解析(HTTP ${res.status})`);
119
+ }
120
+ const code = Number(parsed.code);
121
+ if (code === 1001) {
122
+ const e = new Error(String(parsed.message || 'Trae JWT 已被服务端吊销'));
123
+ e.auth = true;
124
+ throw e;
125
+ }
126
+ return { status: res.status, body: parsed, uid, code: Number.isFinite(code) ? code : null, message: String(parsed.message || '') };
127
+ }
128
+
129
+ /** claim/status 的业务失败(code !== 0)不带 HTTP 错误状态,按 code 与 message 判定 */
130
+ const authDead = (e) => Boolean(e && e.auth);
131
+
132
+ /**
133
+ * 额度:ide_user_ent_usage 的权益包列表 → CreditDaddy 统一结构。
134
+ * 剩余 = entitlement_base_info.quota.credits_limit − pack 顶层 usage.credits_amount(逐包求和)。
135
+ *
136
+ * ⚠️ 这个接口返回裸对象,没有 { code, message } 信封(与签到接口不同),
137
+ * 只有 code 存在且非 0 才算业务失败。总额度优先取服务端自报的 usage_summary,
138
+ * 逐包明细仍按 pack 展开(面板要显示每包到期时间)。
139
+ */
140
+ export async function fetchTraeQuota(account) {
141
+ const r = await post(USAGE_URL, account, { require_usage: true, req_source: 2 });
142
+ if (r.code !== null && r.code !== 0) throw new Error(`Trae 额度接口返回错误:${r.message || `code ${r.code}`}`);
143
+ const packs = (findField(r.body, 'user_entitlement_pack_list') || []).filter((p) => p && typeof p === 'object');
144
+ // 每天签到会新发一个 150 分 / 7 天有效期的包,账户上同名包能堆到 30 个;
145
+ // 逐包直出会淹掉面板,故按名称归并(到期时间取最早的那个)。
146
+ const byName = new Map();
147
+ let total = 0;
148
+ let used = 0;
149
+ for (const pack of packs) {
150
+ const base = pack.entitlement_base_info || {};
151
+ const limit = Number(base.quota?.credits_limit);
152
+ if (!Number.isFinite(limit) || limit <= 0) continue;
153
+ const spent = Number(pack.usage?.credits_amount) || 0;
154
+ // product_id 209 = Work 积分,其余是通用积分
155
+ const isWork = Number(base.product_id) === 209;
156
+ const name = pack.group_name || pack.display_desc || (isWork ? 'Work 积分' : '通用积分');
157
+ const expiresAt = iso(pack.expire_time ?? base.end_time);
158
+ const prev = byName.get(name);
159
+ if (prev) {
160
+ prev.total += limit;
161
+ prev.used += spent;
162
+ if (expiresAt && (!prev.expiresAt || expiresAt < prev.expiresAt)) prev.expiresAt = expiresAt;
163
+ } else {
164
+ byName.set(name, { name, total: limit, used: spent, expiresAt, recurring: !isWork, unit: '积分' });
165
+ }
166
+ total += limit;
167
+ used += spent;
168
+ }
169
+ const parts = [...byName.values()]
170
+ .map((p) => ({ ...p, total: round2(p.total), used: round2(p.used), remaining: round2(Math.max(p.total - p.used, 0)) }))
171
+ .sort((a, b) => b.remaining - a.remaining);
172
+ const summary = r.body.usage_summary && typeof r.body.usage_summary === 'object' ? r.body.usage_summary : null;
173
+ if (summary && Number(summary.total_amount) > 0) {
174
+ total = Number(summary.total_amount);
175
+ used = Number(summary.consumed_amount) || 0;
176
+ }
177
+ const plan = await fetchTraePlan(account).catch(() => null);
178
+ return {
179
+ total: round2(total),
180
+ used: round2(used),
181
+ remaining: round2(Math.max(total - used, 0)),
182
+ exceeded: total > 0 && total - used <= 0,
183
+ // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
184
+ // Trae 与 Qoder 同为「积分」口径,必须计入剩余积分合计。
185
+ plan: plan?.plan || null,
186
+ planExpiresAt: plan?.expiresAt || null,
187
+ parts,
188
+ empty: parts.length === 0 && !summary,
189
+ };
190
+ }
191
+
192
+ const round2 = (n) => Math.round(n * 100) / 100;
193
+
194
+ /** ide_user_pay_status → 套餐名(Free / Lite / Pro)与到期时间;查不到不影响额度 */
195
+ async function fetchTraePlan(account) {
196
+ const r = await post(PAY_STATUS_URL, account, { req_source: 2 });
197
+ if (r.code !== null && r.code !== 0) return null;
198
+ const name = findField(r.body, 'user_pay_identity_str');
199
+ const expire = findField(r.body, 'end_time') ?? findField(r.body, 'next_billing_time');
200
+ return { plan: typeof name === 'string' && name ? name : null, expiresAt: iso(expire) };
201
+ }
202
+
203
+ /**
204
+ * 每日签到:先查 status,已签则报 already,未签才 claim。
205
+ * 返回 CreditDaddy 的签到契约 { status, claimedAmount?, message?, uid? }。
206
+ */
207
+ export async function checkinTrae(account, ctx = {}) {
208
+ const uid = account.uid || uidFromJwt(account.token || '');
209
+ const label = account.name || account.id;
210
+ const base = { accountId: account.id, account: label, provider: 'trae' };
211
+ let r;
212
+ try {
213
+ r = await post(STATUS_URL, account);
214
+ } catch (e) {
215
+ if (authDead(e)) throw e;
216
+ return { ...base, status: 'failed', message: e.message, uid };
217
+ }
218
+ if (r.code !== 0) {
219
+ return { ...base, status: r.code === 9074 ? 'limited' : 'failed', message: r.message || `code ${r.code}`, uid };
220
+ }
221
+ const already = findField(r.body, 'checked_in');
222
+ if (already === true || already === 1) {
223
+ return { ...base, status: 'already', message: '今天已签到', uid };
224
+ }
225
+
226
+ const c = await post(CLAIM_URL, account);
227
+ if (c.code !== 0) {
228
+ ctx.log?.(`Trae 领取失败:${c.message || `code ${c.code}`}`);
229
+ return {
230
+ ...base,
231
+ status: c.code === 9074 ? 'limited' : 'failed',
232
+ message: c.message || `code ${c.code}`,
233
+ uid,
234
+ };
235
+ }
236
+ const claimed = Number(findField(c.body, 'credits')) || 0;
237
+ return { ...base, status: 'checked-in', claimedAmount: claimed, message: claimed ? `签到成功,+${claimed} 积分` : '签到成功', uid };
238
+ }
239
+
240
+ /**
241
+ * 添加账号时的校验:Trae 的 token 是本地可自证的 JWT(含 uid 与到期时间),
242
+ * 不需要网络往返;凭据只来自 Trae 客户端登录后的本机导入。
243
+ */
244
+ export async function verifyTraeAccount(account) {
245
+ const token = account.token;
246
+ if (!token || token.split('.').length !== 3) throw new Error('不是 Trae 的 Cloud-IDE-JWT token');
247
+ let payload;
248
+ try { payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch {
249
+ throw new Error('Trae token 的 JWT 负载无法解析');
250
+ }
251
+ const uid = payload?.data?.id ? String(payload.data.id) : null;
252
+ if (!uid) throw new Error('Trae token 里缺少用户 id');
253
+ const exp = Number(payload.exp);
254
+ if (Number.isFinite(exp) && exp * 1000 < Date.now()) {
255
+ throw new Error(`这个 Trae token 已于 ${new Date(exp * 1000).toLocaleString()} 过期,请到 Trae 客户端重新登录后重新导入`);
256
+ }
257
+ return { uid, name: account.name || null, email: account.email || null };
258
+ }