creditdaddy 0.8.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/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机 ZCode 客户端凭据 — 读取已登录账号、切换当前账号。移植自 zcode-switch(MIT)src-tauri/src/store.rs。
|
|
3
|
+
*
|
|
4
|
+
* ZCode(智谱 GLM / Z.ai 桌面客户端)把当前登录态存在:
|
|
5
|
+
* ~/.zcode/v2/credentials.json 当前登录凭据(值多为 enc:v1: 加密,见 zcrypto.js)
|
|
6
|
+
* ~/.zcode/v2/config.json 当前 provider/模型配置(部分账号在此处存明文 API Key)
|
|
7
|
+
* ~/.zcode/v2/telemetry-state.json { deviceMid, lastDailyActiveDate }
|
|
8
|
+
* 只有一份"当前登录",没有像 WorkBuddy 那样的历史会话文件,所以 CreditDaddy 侧的多账号来自
|
|
9
|
+
* 每次 capture(拉取当前登录存一份)积累,而不是一次性读到全部账号。
|
|
10
|
+
*
|
|
11
|
+
* 切换账号(cold switch):
|
|
12
|
+
* - ZCode 正在运行时默认拒绝(避免运行中的客户端把内存里的旧登录覆盖回文件,即"assets 时间差"问题)
|
|
13
|
+
* - 写回前由调用方(daemon switch 路由)先把当前 live 登录同步 / 保存进账号库,绝不丢号
|
|
14
|
+
* - 每个 CreditDaddy 账号维护一个 virtual device_mid(随机 UUID,落盘进账号记录),切换时写回
|
|
15
|
+
* telemetry-state.json,让不同账号在 ZCode 风控眼里是不同设备,避免账号间互相牵连
|
|
16
|
+
* 不做 zcode-switch 的“热切换”(客户端运行中不重启直接换登录):那需要跟客户端内存状态打配合,
|
|
17
|
+
* 风险较高,这里只做“先确认客户端已退出”的冷切换,更符合“数据只读改,绝不直接动运行中的进程”的边界。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { execSync } from 'node:child_process';
|
|
25
|
+
import * as zc from './zcrypto.js';
|
|
26
|
+
|
|
27
|
+
function home() {
|
|
28
|
+
return process.env.ZCODE_HOME || os.homedir();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function zcodePaths() {
|
|
32
|
+
const h = home();
|
|
33
|
+
const v2 = path.join(h, '.zcode', 'v2');
|
|
34
|
+
return {
|
|
35
|
+
home: h,
|
|
36
|
+
credentials: path.join(v2, 'credentials.json'),
|
|
37
|
+
config: path.join(v2, 'config.json'),
|
|
38
|
+
telemetry: path.join(v2, 'telemetry-state.json'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJson(file) {
|
|
43
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function atomicWriteJson(file, obj) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
const tmp = `${file}.tmp-${crypto.randomUUID()}`;
|
|
49
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
50
|
+
fs.renameSync(tmp, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectZcode() {
|
|
54
|
+
const p = zcodePaths();
|
|
55
|
+
const creds = readJson(p.credentials);
|
|
56
|
+
return {
|
|
57
|
+
dataDir: path.dirname(p.credentials),
|
|
58
|
+
exists: fs.existsSync(p.credentials),
|
|
59
|
+
signedIn: Boolean(creds && zc.isLoggedIn(creds)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 当前登录凭据 → 账号记录(供 addAccount),不做网络请求。返回 null 表示未登录或读取失败。 */
|
|
64
|
+
export function liveToAccount(secretOpts) {
|
|
65
|
+
const p = zcodePaths();
|
|
66
|
+
const creds = readJson(p.credentials);
|
|
67
|
+
if (!creds || !zc.isLoggedIn(creds)) return null;
|
|
68
|
+
const secret = zc.defaultSecret(p.home, secretOpts);
|
|
69
|
+
const id = zc.identityWithSecret(creds, secret);
|
|
70
|
+
return {
|
|
71
|
+
provider: 'zcode',
|
|
72
|
+
// ZCode 没有单一 access_token 字段可直接当 Bearer 用(quota 会在多个候选 token 里试),
|
|
73
|
+
// 这里存整份 creds 快照(仍是各字段各自的 enc:v1 密文),token 字段留一个可读标记供列表展示。
|
|
74
|
+
token: `zcode-creds:${id.userId || crypto.randomUUID()}`,
|
|
75
|
+
uid: id.userId,
|
|
76
|
+
name: zc.identityLabel(id),
|
|
77
|
+
email: id.email,
|
|
78
|
+
source: 'local-app',
|
|
79
|
+
meta: {
|
|
80
|
+
credentials: creds,
|
|
81
|
+
config: readJson(p.config),
|
|
82
|
+
canonicalHash: zc.canonicalHash(creds),
|
|
83
|
+
// 沿用本机当前的设备 ID:切回这个账号时还原成它原本的设备身份
|
|
84
|
+
deviceMid: readJson(p.telemetry)?.deviceMid || null,
|
|
85
|
+
capturedAt: new Date().toISOString(),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** ZCode 客户端当前登录账号的完整身份(未登录返回 null) */
|
|
91
|
+
export function currentZcodeIdentity() {
|
|
92
|
+
const p = zcodePaths();
|
|
93
|
+
const creds = readJson(p.credentials);
|
|
94
|
+
if (!creds || !zc.isLoggedIn(creds)) return null;
|
|
95
|
+
const id = zc.identityWithSecret(creds, zc.defaultSecret(p.home));
|
|
96
|
+
return { uid: id.userId ?? null, email: id.email || null, username: id.username || null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** ZCode 客户端当前登录账号的 uid(未登录返回 null) */
|
|
100
|
+
export function currentZcodeUid() {
|
|
101
|
+
const id = currentZcodeIdentity();
|
|
102
|
+
return id?.uid != null ? String(id.uid) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 账号库里是否已有同一登录(先比规范化哈希,哈希对不上再比身份) */
|
|
106
|
+
export function findSameLogin(liveCreds, secret, existingAccounts) {
|
|
107
|
+
const hash = zc.canonicalHash(liveCreds);
|
|
108
|
+
const byHash = existingAccounts.find((a) => a.meta?.canonicalHash === hash);
|
|
109
|
+
if (byHash) return byHash;
|
|
110
|
+
const liveId = zc.identityWithSecret(liveCreds, secret);
|
|
111
|
+
if (!liveId.userId && !liveId.username && !liveId.email) return null;
|
|
112
|
+
return existingAccounts.find((a) => {
|
|
113
|
+
const cid = a.meta?.credentials && zc.identityWithSecret(a.meta.credentials, secret);
|
|
114
|
+
if (!cid) return false;
|
|
115
|
+
return Boolean(
|
|
116
|
+
(liveId.userId && cid.userId && liveId.userId === cid.userId)
|
|
117
|
+
|| (liveId.email && cid.email && liveId.email === cid.email)
|
|
118
|
+
|| (liveId.username && cid.username && liveId.username === cid.username),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const WIN_TASKLIST = () => {
|
|
124
|
+
try {
|
|
125
|
+
return execSync('tasklist /FI "IMAGENAME eq ZCode.exe" /NH', { windowsHide: true, encoding: 'utf8' });
|
|
126
|
+
} catch { return ''; }
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** ZCode 客户端是否在运行(仅 Windows 有实现;其他平台保守返回 false,即“允许切换”) */
|
|
130
|
+
export function zcodeRunning() {
|
|
131
|
+
if (process.platform !== 'win32') return false;
|
|
132
|
+
return /zcode\.exe/i.test(WIN_TASKLIST());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 结束 ZCode 客户端进程(先优雅关闭,超时后强制 kill)。
|
|
139
|
+
* 只在用户明确选择「强制切换」时调用:运行中的客户端会把内存里的旧登录覆盖回
|
|
140
|
+
* credentials.json,只写文件不关进程等于没切换。
|
|
141
|
+
* 注意:不要在测试里调用——它会真的结束本机 ZCode。
|
|
142
|
+
*/
|
|
143
|
+
export function terminateZcode({ timeoutMs = 8000 } = {}) {
|
|
144
|
+
if (process.platform !== 'win32') return { closed: false, supported: false };
|
|
145
|
+
if (!zcodeRunning()) return { closed: false, running: false };
|
|
146
|
+
try { execSync('taskkill /IM ZCode.exe /T', { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
147
|
+
let deadline = Date.now() + timeoutMs;
|
|
148
|
+
while (Date.now() < deadline && zcodeRunning()) nap(250);
|
|
149
|
+
if (zcodeRunning()) {
|
|
150
|
+
try { execSync('taskkill /IM ZCode.exe /T /F', { windowsHide: true, stdio: 'ignore' }); } catch {}
|
|
151
|
+
deadline = Date.now() + 3000;
|
|
152
|
+
while (Date.now() < deadline && zcodeRunning()) nap(250);
|
|
153
|
+
}
|
|
154
|
+
return { closed: !zcodeRunning(), running: zcodeRunning() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 切换到某个 CreditDaddy 账号对应的 ZCode 登录(冷切换)。
|
|
159
|
+
* @param {object} account 目标账号(需带 meta.credentials,来自本机导入)
|
|
160
|
+
* 调用方须在此之前把当前 live 登录保存进账号库(防丢号,见 daemon 的 switch 路由)。
|
|
161
|
+
* @param {{force?: boolean}} opts force:ZCode 正在运行时也强制写(不建议:客户端退出前可能把内存里的旧登录覆盖回文件)
|
|
162
|
+
* @returns {{switched: boolean, alreadyActive: boolean}}
|
|
163
|
+
*/
|
|
164
|
+
export function switchTo(account, { force = false } = {}) {
|
|
165
|
+
if (!account?.meta?.credentials) {
|
|
166
|
+
throw new Error('该账号没有保存 ZCode 凭据快照,无法切换(请重新从本机导入)');
|
|
167
|
+
}
|
|
168
|
+
const p = zcodePaths();
|
|
169
|
+
const secret = zc.defaultSecret(p.home);
|
|
170
|
+
const live = readJson(p.credentials);
|
|
171
|
+
const liveHash = live ? zc.canonicalHash(live) : null;
|
|
172
|
+
const targetHash = account.meta.canonicalHash || zc.canonicalHash(account.meta.credentials);
|
|
173
|
+
|
|
174
|
+
let alreadyActive = liveHash === targetHash;
|
|
175
|
+
if (!alreadyActive && live && zc.isLoggedIn(live)) {
|
|
176
|
+
const liveId = zc.identityWithSecret(live, secret);
|
|
177
|
+
const targetId = zc.identityWithSecret(account.meta.credentials, secret);
|
|
178
|
+
const hasSignal = (id) => Boolean(id.userId || id.username || id.email);
|
|
179
|
+
if (hasSignal(liveId) && hasSignal(targetId)) {
|
|
180
|
+
alreadyActive = Boolean(
|
|
181
|
+
(liveId.userId && targetId.userId && liveId.userId === targetId.userId)
|
|
182
|
+
|| (liveId.email && targetId.email && liveId.email === targetId.email),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (alreadyActive) {
|
|
187
|
+
writeVirtualDeviceMid(account);
|
|
188
|
+
return { switched: false, alreadyActive: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!force && zcodeRunning()) {
|
|
192
|
+
const err = new Error('ZCode 客户端正在运行,请先退出后再切换(或强制切换,但客户端可能把内存里的旧登录覆盖回文件)');
|
|
193
|
+
err.zcodeRunning = true;
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
atomicWriteJson(p.credentials, account.meta.credentials);
|
|
198
|
+
if (account.meta.config) atomicWriteJson(p.config, account.meta.config);
|
|
199
|
+
writeVirtualDeviceMid(account);
|
|
200
|
+
return { switched: true, alreadyActive: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 每个账号的虚拟设备 ID(随机生成一次,落在 account.meta.deviceMid),切换时写回 telemetry-state.json */
|
|
204
|
+
export function ensureVirtualDeviceMid(account) {
|
|
205
|
+
if (account.meta?.deviceMid) return account.meta.deviceMid;
|
|
206
|
+
const mid = crypto.randomUUID();
|
|
207
|
+
account.meta = { ...(account.meta || {}), deviceMid: mid };
|
|
208
|
+
return mid;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function writeVirtualDeviceMid(account) {
|
|
212
|
+
const mid = ensureVirtualDeviceMid(account);
|
|
213
|
+
const p = zcodePaths();
|
|
214
|
+
const tele = readJson(p.telemetry) || {};
|
|
215
|
+
if (tele.deviceMid === mid) return;
|
|
216
|
+
atomicWriteJson(p.telemetry, { ...tele, deviceMid: mid });
|
|
217
|
+
}
|
package/src/zcrypto.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZCode(智谱 GLM / Z.ai 桌面客户端)本机凭据加解密 — 移植自 zcode-switch(MIT)src-tauri/src/zcrypto.rs。
|
|
3
|
+
*
|
|
4
|
+
* 凭据文件 ~/.zcode/v2/credentials.json 里每个值可能是:
|
|
5
|
+
* - 明文字符串
|
|
6
|
+
* - "enc:v1:<nonce>.<tag>.<密文>"(均为 URL-safe base64,无 padding),AES-256-GCM,
|
|
7
|
+
* key = SHA256(secret),secret 默认 "zcode-credential-fallback:<平台>:<home>:<用户名>",
|
|
8
|
+
* 可用环境变量 ZCODE_CREDENTIAL_SECRET 覆盖(客户端本身也读这个变量)。
|
|
9
|
+
* 纯函数、无 I/O,供 zcodeLocal.js 调用,也便于单测。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export const ENC_PREFIX = 'enc:v1:';
|
|
15
|
+
|
|
16
|
+
/** node/rust 平台名 → zcode 使用的平台标识(win32 / darwin / linux) */
|
|
17
|
+
export function nodePlatformFor(platform) {
|
|
18
|
+
if (platform === 'windows') return 'win32';
|
|
19
|
+
if (platform === 'macos') return 'darwin';
|
|
20
|
+
return platform;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 未设置 ZCODE_CREDENTIAL_SECRET 时的回退密钥来源 */
|
|
24
|
+
export function defaultSecret(home, { platform = process.platform, username } = {}) {
|
|
25
|
+
const envSecret = process.env.ZCODE_CREDENTIAL_SECRET;
|
|
26
|
+
if (envSecret) return envSecret;
|
|
27
|
+
const user = username || process.env.USERNAME || process.env.USER || process.env.LOGNAME || 'unknown';
|
|
28
|
+
return `zcode-credential-fallback:${platform}:${home}:${user}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deriveKey(secret) {
|
|
32
|
+
return crypto.createHash('sha256').update(secret, 'utf8').digest();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isEncrypted(v) {
|
|
36
|
+
return typeof v === 'string' && v.startsWith(ENC_PREFIX);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function b64u(s) {
|
|
40
|
+
return Buffer.from(s, 'base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 解密单个 "enc:v1:…" 值;输入非该格式时报错(调用方按需 try/catch 或用 safeDecrypt) */
|
|
44
|
+
export function decryptWithSecret(value, secret) {
|
|
45
|
+
if (!isEncrypted(value)) throw new Error('不是 enc:v1 格式');
|
|
46
|
+
const parts = value.slice(ENC_PREFIX.length).split('.');
|
|
47
|
+
if (parts.length !== 3) throw new Error('enc:v1 格式不正确');
|
|
48
|
+
const [nonceB64, tagB64, ctB64] = parts;
|
|
49
|
+
const nonce = b64u(nonceB64);
|
|
50
|
+
if (nonce.length !== 12) throw new Error('nonce 长度异常');
|
|
51
|
+
const tag = b64u(tagB64);
|
|
52
|
+
const ct = b64u(ctB64);
|
|
53
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
54
|
+
decipher.setAuthTag(tag);
|
|
55
|
+
try {
|
|
56
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error('解密失败(密钥不匹配或数据损坏)');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 加密成 "enc:v1:…"(与 decryptWithSecret 互逆):浏览器登录拿到的凭据按客户端格式落盘,切换时才能被 ZCode 读懂 */
|
|
63
|
+
export function encryptWithSecret(plain, secret) {
|
|
64
|
+
const nonce = crypto.randomBytes(12);
|
|
65
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce);
|
|
66
|
+
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
67
|
+
return ENC_PREFIX + [nonce, cipher.getAuthTag(), ct].map((b) => b.toString('base64url')).join('.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 值可能加密也可能明文,统一转明文;失败返回 null(不抛错) */
|
|
71
|
+
export function safeDecrypt(value, secret) {
|
|
72
|
+
if (typeof value !== 'string') return null;
|
|
73
|
+
if (!isEncrypted(value)) return value;
|
|
74
|
+
try { return decryptWithSecret(value, secret); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 明文/加密的 JSON 字符串 → 解析后的对象;失败返回 null */
|
|
78
|
+
export function decryptJsonOpt(value, secret) {
|
|
79
|
+
const plain = safeDecrypt(value, secret);
|
|
80
|
+
if (plain === null) return null;
|
|
81
|
+
try { return JSON.parse(plain); } catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 解码 JWT payload(不校验签名,仅用于展示/识别用户,与 zcode 客户端自身用途一致) */
|
|
85
|
+
export function decodeJwt(jwt) {
|
|
86
|
+
try {
|
|
87
|
+
const seg = String(jwt).split('.')[1];
|
|
88
|
+
if (!seg) return null;
|
|
89
|
+
return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 从凭据对象里提取账号身份:{ provider, username, displayName, email, userId }。
|
|
97
|
+
* 逻辑对齐 zcrypto.rs identity_with_secret:先看 active_provider 的 user_info,
|
|
98
|
+
* 找不到 user_id 时退而解码 access_token 这个 JWT。
|
|
99
|
+
*/
|
|
100
|
+
export function identityWithSecret(creds, secret) {
|
|
101
|
+
const id = { provider: 'bigmodel', username: null, displayName: null, email: null, userId: null };
|
|
102
|
+
if (!creds || typeof creds !== 'object') return id;
|
|
103
|
+
const apRaw = creds['oauth:active_provider'];
|
|
104
|
+
if (typeof apRaw === 'string') {
|
|
105
|
+
const plain = isEncrypted(apRaw) ? safeDecrypt(apRaw, secret) : apRaw;
|
|
106
|
+
if (plain) id.provider = plain;
|
|
107
|
+
}
|
|
108
|
+
const ui = decryptJsonOpt(creds[`oauth:${id.provider}:user_info`], secret);
|
|
109
|
+
if (ui && typeof ui === 'object') {
|
|
110
|
+
id.username = typeof ui.username === 'string' ? ui.username : null;
|
|
111
|
+
id.displayName = typeof ui.displayName === 'string' ? ui.displayName : null;
|
|
112
|
+
id.email = typeof ui.email === 'string' ? ui.email
|
|
113
|
+
: typeof ui.rawProfile?.email === 'string' ? ui.rawProfile.email : null;
|
|
114
|
+
if (ui.id !== undefined && ui.id !== null) {
|
|
115
|
+
id.userId = typeof ui.id === 'string' ? ui.id : JSON.stringify(ui.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!id.userId) {
|
|
119
|
+
const at = creds[`oauth:${id.provider}:access_token`];
|
|
120
|
+
const plain = typeof at === 'string' ? (isEncrypted(at) ? safeDecrypt(at, secret) : at) : null;
|
|
121
|
+
const jwt = plain ? decodeJwt(plain) : null;
|
|
122
|
+
if (jwt) id.userId = jwt.user_id ?? jwt.sub ?? null;
|
|
123
|
+
}
|
|
124
|
+
return id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function identityLabel(id) {
|
|
128
|
+
return id.displayName || id.username || id.email || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 是否已登录:有任意 oauth:*:access_token,或有非空 zcodejwttoken */
|
|
132
|
+
export function isLoggedIn(creds) {
|
|
133
|
+
if (!creds || typeof creds !== 'object') return false;
|
|
134
|
+
if (Object.keys(creds).some((k) => k.startsWith('oauth:') && k.endsWith(':access_token'))) return true;
|
|
135
|
+
const jwt = creds.zcodejwttoken;
|
|
136
|
+
return typeof jwt === 'string' && jwt.trim().length > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEVICE_KEY_PREFIX = 'web-remote-control:';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 账号去重用的规范化哈希:剔除设备相关键(web-remote-control:…,含远程控制中继密钥,
|
|
143
|
+
* 这些跟着设备走而不是账号,不能影响“是否是同一个登录”的判断)后对 JSON 取 SHA256。
|
|
144
|
+
*/
|
|
145
|
+
function stableStringify(v) {
|
|
146
|
+
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
|
|
147
|
+
if (v && typeof v === 'object') {
|
|
148
|
+
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
|
|
149
|
+
}
|
|
150
|
+
return JSON.stringify(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function canonicalHash(creds) {
|
|
154
|
+
let obj = creds;
|
|
155
|
+
if (creds && typeof creds === 'object' && !Array.isArray(creds)) {
|
|
156
|
+
obj = Object.fromEntries(Object.entries(creds).filter(([k]) => !k.startsWith(DEVICE_KEY_PREFIX)));
|
|
157
|
+
}
|
|
158
|
+
// 键序无关的稳定序列化:credentials.json 被 ZCode 重写后键序可能变,但同一登录应得同一 hash
|
|
159
|
+
return crypto.createHash('sha256').update(stableStringify(obj)).digest('hex');
|
|
160
|
+
}
|