opencode-lobster-auth 0.1.1 → 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/README.md +255 -175
- package/README.zh-CN.md +211 -151
- package/index.mjs +226 -191
- package/lib/assemble.mjs +7 -1
- package/lib/auth-import.mjs +70 -5
- package/lib/auth-login.mjs +154 -131
- package/lib/checkin.mjs +240 -0
- package/lib/constants.mjs +67 -41
- package/lib/jwt.mjs +34 -34
- package/lib/models.mjs +11 -5
- package/lib/proxy.mjs +51 -19
- package/lib/sse.mjs +63 -67
- package/lib/stream-error.mjs +34 -0
- package/lib/thinking.mjs +58 -12
- package/lib/usage.mjs +46 -4
- package/package.json +38 -37
package/index.mjs
CHANGED
|
@@ -1,191 +1,226 @@
|
|
|
1
|
-
import { PROVIDER, NAME, API, CHAT_BASE, NPM, FALLBACK_MODELS } from "./lib/constants.mjs"
|
|
2
|
-
import { makeFetch } from "./lib/proxy.mjs"
|
|
3
|
-
import { modelsFromCatalog, profileOfFrom } from "./lib/models.mjs"
|
|
4
|
-
import { usageFrom } from "./lib/usage.mjs"
|
|
5
|
-
import { desktopSignIn, desktopToken } from "./lib/auth-import.mjs"
|
|
6
|
-
import { beginSignIn } from "./lib/auth-login.mjs"
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1
|
+
import { PROVIDER, NAME, API, CHAT_BASE, NPM, FALLBACK_MODELS } from "./lib/constants.mjs"
|
|
2
|
+
import { makeFetch } from "./lib/proxy.mjs"
|
|
3
|
+
import { modelsFromCatalog, profileOfFrom } from "./lib/models.mjs"
|
|
4
|
+
import { usageFrom } from "./lib/usage.mjs"
|
|
5
|
+
import { desktopSignIn, desktopToken } from "./lib/auth-import.mjs"
|
|
6
|
+
import { beginSignIn, beginSignInAnnounced } from "./lib/auth-login.mjs"
|
|
7
|
+
import { checkinWith } from "./lib/checkin.mjs"
|
|
8
|
+
import { jwtExp } from "./lib/jwt.mjs"
|
|
9
|
+
|
|
10
|
+
const REFRESH_LEAD = 6 * 3600 * 1000
|
|
11
|
+
|
|
12
|
+
// 每个账号的模型 → 思考档位,供请求管线查表
|
|
13
|
+
const profiles = new Map()
|
|
14
|
+
|
|
15
|
+
async function apiGet(path, token, timeout = 20_000) {
|
|
16
|
+
const res = await fetch(API + path, {
|
|
17
|
+
headers: { authorization: `Bearer ${token}` },
|
|
18
|
+
signal: AbortSignal.timeout(timeout),
|
|
19
|
+
})
|
|
20
|
+
const text = await res.text()
|
|
21
|
+
let v
|
|
22
|
+
try { v = JSON.parse(text) } catch { throw new Error(`LobsterAI ${path}: 响应无法解析`) }
|
|
23
|
+
if (res.status === 401) {
|
|
24
|
+
throw Object.assign(new Error("LobsterAI 的登录已过期,请重新登录"), { signIn: "expired" })
|
|
25
|
+
}
|
|
26
|
+
if (v?.code !== 0) throw new Error(`LobsterAI ${path}: ${v?.message || `code ${v?.code}`}`)
|
|
27
|
+
return v.data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 取这次请求要用的 token。
|
|
31
|
+
// 桌面端导入的账号自己不存 token(见 lib/auth-import.mjs),每次现读 ——
|
|
32
|
+
// 桌面端续期后插件自动跟上。读不到、或桌面端已经换成别的账号,都抛
|
|
33
|
+
// signIn:"expired"(多账号时后者防止串号)。
|
|
34
|
+
async function tokenOf(auth) {
|
|
35
|
+
if (!auth) return ""
|
|
36
|
+
if (auth.source === "desktop") return desktopToken(process.env, auth.accountId, auth.uid)
|
|
37
|
+
return auth.type === "oauth" ? (auth.access ?? "") : (auth.key ?? "")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const LobsterAuthPlugin = async () => ({
|
|
41
|
+
config: async (cfg) => {
|
|
42
|
+
cfg.provider ??= {}
|
|
43
|
+
cfg.provider[PROVIDER] ??= {
|
|
44
|
+
name: NAME,
|
|
45
|
+
npm: NPM,
|
|
46
|
+
api: API,
|
|
47
|
+
models: FALLBACK_MODELS,
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
auth: {
|
|
52
|
+
provider: PROVIDER,
|
|
53
|
+
refreshLead: REFRESH_LEAD,
|
|
54
|
+
|
|
55
|
+
methods: [
|
|
56
|
+
{
|
|
57
|
+
type: "oauth",
|
|
58
|
+
label: "浏览器登录(有道账号)",
|
|
59
|
+
async authorize() {
|
|
60
|
+
// 由 magpie 打开这个 url;插件不再自己开一次,否则会开两个标签页
|
|
61
|
+
const pending = await beginSignIn()
|
|
62
|
+
return {
|
|
63
|
+
url: pending.url,
|
|
64
|
+
instructions: "在浏览器里用有道账号登录,完成后回到这里。",
|
|
65
|
+
method: "auto",
|
|
66
|
+
callback: () => pending.finish(),
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
// 浏览器在另一台机器上(magpie 跑在 NAS / Docker 里,用这台电脑的
|
|
72
|
+
// 浏览器打开它的页面):回调落在浏览器的 127.0.0.1,到不了 magpie。
|
|
73
|
+
// 登录地址在普通 query 里写明了本机回调端口,magpie 因此会为这次
|
|
74
|
+
// 登录显示「回调 URL」输入框;把登录后地址栏里的完整地址粘进去,
|
|
75
|
+
// magpie 在本机把请求送到插件正在监听的端口,回调照常抵达,
|
|
76
|
+
// 剩下的与「浏览器登录」完全一样。
|
|
77
|
+
type: "oauth",
|
|
78
|
+
label: "远程浏览器登录",
|
|
79
|
+
async authorize() {
|
|
80
|
+
const pending = await beginSignInAnnounced()
|
|
81
|
+
return {
|
|
82
|
+
url: pending.url,
|
|
83
|
+
instructions: "在浏览器里用有道账号登录。登录后浏览器会跳到 127.0.0.1 并提示无法访问,这是预期现象:复制此时地址栏的完整地址,粘贴到下面的「回调 URL」,再点「完成登录」。请在 5 分钟内完成;回调地址包含一次性授权信息,不要发给别人。",
|
|
84
|
+
method: "auto",
|
|
85
|
+
callback: () => pending.finish(),
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
// 必须是 oauth:api 类型会让 magpie 强制从 stdin 读一个 key,
|
|
91
|
+
// 而这条路根本不需要用户输入任何东西(见设计文档 §4.3)。
|
|
92
|
+
type: "oauth",
|
|
93
|
+
label: "从本机 LobsterAI 导入登录",
|
|
94
|
+
async authorize() {
|
|
95
|
+
// url 留空:magpie 不打开任何页面,直接走 callback
|
|
96
|
+
return {
|
|
97
|
+
url: "",
|
|
98
|
+
instructions: "直接使用本机 LobsterAI 桌面端已登录的账号。",
|
|
99
|
+
method: "auto",
|
|
100
|
+
async callback() {
|
|
101
|
+
try {
|
|
102
|
+
return desktopSignIn()
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { type: "failed", error: e.message }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
|
|
112
|
+
// 只对路径 A(浏览器登录,有 refresh 和 expires)生效。
|
|
113
|
+
// 路径 B(桌面端导入)的 refresh 是空串、expires 为 0,
|
|
114
|
+
// magpie 不会调用这里,桌面端的会话因此不会被搅动。
|
|
115
|
+
async refresh(auth) {
|
|
116
|
+
if (auth?.type !== "oauth" || auth.source === "desktop" || !auth.refresh) return {}
|
|
117
|
+
const res = await fetch(`${API}/auth/refresh`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ refreshToken: auth.refresh }),
|
|
121
|
+
signal: AbortSignal.timeout(30_000),
|
|
122
|
+
})
|
|
123
|
+
const text = await res.text()
|
|
124
|
+
let v
|
|
125
|
+
try { v = JSON.parse(text) } catch { throw new Error("LobsterAI 续期:响应无法解析") }
|
|
126
|
+
if (v?.code !== 0 || !v?.data?.accessToken) {
|
|
127
|
+
throw Object.assign(new Error(`LobsterAI 续期失败:${v?.message || `code ${v?.code}`}`), { signIn: "expired" })
|
|
128
|
+
}
|
|
129
|
+
const d = v.data
|
|
130
|
+
return {
|
|
131
|
+
access: String(d.accessToken),
|
|
132
|
+
refresh: String(d.refreshToken ?? auth.refresh),
|
|
133
|
+
// 上游可能不给新 token 的 exp;读不到就沿用旧的到期时间
|
|
134
|
+
expires: jwtExp(d.accessToken) || auth.expires || 0,
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async loader(getAuth) {
|
|
139
|
+
const auth = await getAuth()
|
|
140
|
+
// 这里只是给 magpie 一个「看起来已登录」的信号;真正的 token
|
|
141
|
+
// 由下面的 fetch 每次请求现取(桌面端账号尤其需要)。
|
|
142
|
+
let token = ""
|
|
143
|
+
try { token = await tokenOf(auth) } catch { return {} }
|
|
144
|
+
if (!token) return {}
|
|
145
|
+
return {
|
|
146
|
+
baseURL: CHAT_BASE,
|
|
147
|
+
apiKey: token,
|
|
148
|
+
headers: {},
|
|
149
|
+
fetch: makeFetch({
|
|
150
|
+
getAuth,
|
|
151
|
+
tokenOf,
|
|
152
|
+
profileOf: (id) => profiles.get(String(id)),
|
|
153
|
+
call: (input, init) => fetch(input, init),
|
|
154
|
+
}),
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async usage(getAuth) {
|
|
159
|
+
const auth = await getAuth()
|
|
160
|
+
let token = ""
|
|
161
|
+
try { token = await tokenOf(auth) } catch (e) {
|
|
162
|
+
return { error: e.message, windows: [], signIn: e.signIn === "expired" ? "expired" : "kept" }
|
|
163
|
+
}
|
|
164
|
+
if (!token) return { error: "尚未登录", windows: [] }
|
|
165
|
+
try {
|
|
166
|
+
const [quota, profile] = await Promise.all([
|
|
167
|
+
apiGet("/user/quota", token),
|
|
168
|
+
apiGet("/user/profile-summary", token),
|
|
169
|
+
])
|
|
170
|
+
// 插件自己不续期(交给 magpie 的 auth.refresh),所以这次请求用的
|
|
171
|
+
// 就是存下来的 token —— 如实报 kept。社区插件报 renewed 是因为它们
|
|
172
|
+
// 自己维护续期表,这里没有那张表,不该照抄。
|
|
173
|
+
return usageFrom(quota, profile, "kept")
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return {
|
|
176
|
+
error: e.message,
|
|
177
|
+
windows: [],
|
|
178
|
+
signIn: e.signIn === "expired" ? "expired" : "kept",
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// 每日积分礼(签到)。magpie 在开关打开后,对每个已登录账号每天各调一次。
|
|
184
|
+
// 这是写操作,开关默认关,由用户在 Settings → Usage → Plugins 打开。
|
|
185
|
+
// 逻辑见 lib/checkin.mjs;这里只负责取 token 和兜住异常。
|
|
186
|
+
async checkin(getAuth) {
|
|
187
|
+
const auth = await getAuth()
|
|
188
|
+
let token = ""
|
|
189
|
+
try {
|
|
190
|
+
token = await tokenOf(auth)
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { outcome: "failed", message: e.message }
|
|
193
|
+
}
|
|
194
|
+
return checkinWith(token)
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
provider: {
|
|
199
|
+
id: PROVIDER,
|
|
200
|
+
async models(provider, { auth } = {}) {
|
|
201
|
+
let token = ""
|
|
202
|
+
try { token = await tokenOf(auth) } catch (e) {
|
|
203
|
+
if (e.signIn === "expired") throw e
|
|
204
|
+
return provider.models
|
|
205
|
+
}
|
|
206
|
+
if (!token) return provider.models
|
|
207
|
+
try {
|
|
208
|
+
const list = await apiGet(
|
|
209
|
+
"/models/available?firstKeyfrom=official&latestKeyfrom=official",
|
|
210
|
+
token,
|
|
211
|
+
)
|
|
212
|
+
if (!Array.isArray(list) || list.length === 0) return provider.models
|
|
213
|
+
for (const [id, p] of Object.entries(profileOfFrom(list))) profiles.set(id, p)
|
|
214
|
+
const out = modelsFromCatalog(list, { ...provider, id: PROVIDER })
|
|
215
|
+
return Object.keys(out).length > 0 ? out : provider.models
|
|
216
|
+
} catch (e) {
|
|
217
|
+
// 登录失效要标记账号;其余情况保留原列表
|
|
218
|
+
if (e.signIn === "expired") throw e
|
|
219
|
+
return provider.models
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// 唯一允许的函数导出:magpie 会把每个导出的函数都当成一个插件加载
|
|
226
|
+
export const _internal = { PROVIDER, FALLBACK_MODELS, usageFrom, modelsFromCatalog }
|
package/lib/assemble.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// 流式请求原样转发;非流式请求要把分片拼成一个完整的 chat.completion。
|
|
3
3
|
|
|
4
4
|
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { errorFrame, StreamError } from "./stream-error.mjs"
|
|
6
|
+
export { StreamError } from "./stream-error.mjs"
|
|
5
7
|
|
|
6
8
|
const enc = new TextEncoder()
|
|
7
9
|
|
|
@@ -23,6 +25,8 @@ export async function mergeChunks(records, model) {
|
|
|
23
25
|
let reasoning = ""
|
|
24
26
|
|
|
25
27
|
for await (const rec of records) {
|
|
28
|
+
const bad = errorFrame(rec)
|
|
29
|
+
if (bad) throw new StreamError(bad)
|
|
26
30
|
if (rec.data === "[DONE]") break
|
|
27
31
|
let v
|
|
28
32
|
try { v = JSON.parse(rec.data) } catch { continue }
|
|
@@ -61,7 +65,9 @@ function streamOut(records) {
|
|
|
61
65
|
try {
|
|
62
66
|
for await (const rec of records) {
|
|
63
67
|
if (rec.data === "[DONE]") break
|
|
64
|
-
|
|
68
|
+
const event = rec.event ? `event: ${rec.event}\n` : ""
|
|
69
|
+
const data = rec.data.split("\n").map((line) => `data: ${line}`).join("\n")
|
|
70
|
+
controller.enqueue(enc.encode(`${event}${data}\n\n`))
|
|
65
71
|
}
|
|
66
72
|
controller.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
67
73
|
controller.close()
|
package/lib/auth-import.mjs
CHANGED
|
@@ -30,6 +30,12 @@ const parse = (s) => {
|
|
|
30
30
|
} catch { return null }
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const identityValue = (value) => {
|
|
34
|
+
if (typeof value === "string") return value.trim() || undefined
|
|
35
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value)
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export function readCredentials(path) {
|
|
34
40
|
if (!path || !existsSync(path)) return null
|
|
35
41
|
let db
|
|
@@ -43,11 +49,18 @@ export function readCredentials(path) {
|
|
|
43
49
|
if (!accessToken) return null
|
|
44
50
|
const user = parse(byKey.auth_user) ?? {}
|
|
45
51
|
const claims = accountOf(accessToken)
|
|
52
|
+
// 两行可能分开更新。身份必须来自实际使用的 token;用户行只能在已有
|
|
53
|
+
// 共同标识吻合、且没有任何冲突时补全字段,不能让旧用户行替新 token 作证。
|
|
54
|
+
const fields = ["yid", "id"]
|
|
55
|
+
const shared = fields.filter((key) => identityValue(claims[key]) !== undefined && identityValue(user[key]) !== undefined)
|
|
56
|
+
const identityConflict = shared.some((key) => identityValue(claims[key]) !== identityValue(user[key]))
|
|
57
|
+
const verifiedUser = shared.length > 0 && !identityConflict
|
|
46
58
|
return {
|
|
47
59
|
accessToken,
|
|
48
60
|
refreshToken: String(tokens?.refreshToken ?? ""),
|
|
49
|
-
yid:
|
|
50
|
-
id:
|
|
61
|
+
yid: identityValue(claims.yid) !== undefined ? claims.yid : verifiedUser ? user.yid : undefined,
|
|
62
|
+
id: identityValue(claims.id) !== undefined ? claims.id : verifiedUser ? user.id : undefined,
|
|
63
|
+
identityConflict,
|
|
51
64
|
nickname: user.nickname,
|
|
52
65
|
expires: jwtExp(accessToken),
|
|
53
66
|
}
|
|
@@ -65,6 +78,24 @@ export function readCredentials(path) {
|
|
|
65
78
|
// 为什么不把 token 抄一份:抄下来的 refreshToken 会是桌面端和插件共用的,
|
|
66
79
|
// 谁先续期谁就把对方踢下线。留空则 magpie 永不续期(expires 为 0),
|
|
67
80
|
// 桌面端的会话不受任何影响。
|
|
81
|
+
// 账号身份。magpie 支持一个插件挂多个账号,靠 accountId 区分,所以这个值
|
|
82
|
+
// 既是「这个号叫什么」,也是下次读桌面端时核对「还是不是同一个号」的依据。
|
|
83
|
+
// 优先用 yid(有道的账号标识),没有就退回数字 id 的字符串形式。
|
|
84
|
+
// yid 一律转成字符串:magpie 往返一轮后回来的是字符串,留着数字会让
|
|
85
|
+
// 同一个号在两侧比不相等,把好账号误判成「切换了账号」。
|
|
86
|
+
export function accountIdOf(c) {
|
|
87
|
+
return identityValue(c?.yid) ?? identityValue(c?.id)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireIdentity(c) {
|
|
91
|
+
if (c.identityConflict) {
|
|
92
|
+
throw Object.assign(new Error("LobsterAI 桌面端 token 与账号身份不一致(可能正在切换账号),请稍后重新登录"), { signIn: "expired" })
|
|
93
|
+
}
|
|
94
|
+
if (!accountIdOf(c)) {
|
|
95
|
+
throw Object.assign(new Error("LobsterAI 桌面端 token 的账号身份不可读,请重新登录本账号"), { signIn: "expired" })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
export function desktopSignIn(env = process.env) {
|
|
69
100
|
const path = dbPath(env)
|
|
70
101
|
const c = readCredentials(path)
|
|
@@ -74,21 +105,31 @@ export function desktopSignIn(env = process.env) {
|
|
|
74
105
|
"请先在 LobsterAI 桌面端登录,或改用「浏览器登录」。",
|
|
75
106
|
)
|
|
76
107
|
}
|
|
108
|
+
requireIdentity(c)
|
|
77
109
|
return {
|
|
78
110
|
type: "success",
|
|
79
111
|
refresh: "",
|
|
80
112
|
access: "",
|
|
81
113
|
expires: 0,
|
|
82
114
|
source: "desktop",
|
|
83
|
-
accountId:
|
|
84
|
-
uid
|
|
115
|
+
accountId: accountIdOf(c),
|
|
116
|
+
// magpie 只认字符串 uid(源码 uidOf),存数字等于没存
|
|
117
|
+
uid: identityValue(c.id),
|
|
85
118
|
}
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
// 桌面端账号的 token:每次现读,桌面端换了 token 插件立刻跟上。
|
|
89
122
|
// 读不到就是账号不可用(桌面端退出登录、库被删、文件被加密),
|
|
90
123
|
// 报 signIn:"expired" 让 magpie 给账号打上失效标记,提示用户重新登录。
|
|
91
|
-
|
|
124
|
+
//
|
|
125
|
+
// expected 是这个账号签入时记下的身份,expectedUid 是当时记下的数字 id。
|
|
126
|
+
// 桌面端只有一个「当前登录」,换账号后现读到的就是别人的 token —— 不核对的话,
|
|
127
|
+
// 原账号会显示别人的余额、扣别人的积分、签到签在别人头上。
|
|
128
|
+
//
|
|
129
|
+
// 至少一个绑定标识要匹配,同时不能有其它可比较标识冲突。签入时只有数字 id、
|
|
130
|
+
// 后来补上 yid 的情况仍可按 id 识别;但 uid 相同不能掩盖已绑定 yid 的变化。
|
|
131
|
+
// 没保存绑定身份的老账号仍兼容;token 无身份或与用户行冲突时一律 fail-closed。
|
|
132
|
+
export function desktopToken(env = process.env, expected, expectedUid) {
|
|
92
133
|
const c = readCredentials(dbPath(env))
|
|
93
134
|
if (!c) {
|
|
94
135
|
throw Object.assign(
|
|
@@ -96,5 +137,29 @@ export function desktopToken(env = process.env) {
|
|
|
96
137
|
{ signIn: "expired" },
|
|
97
138
|
)
|
|
98
139
|
}
|
|
140
|
+
requireIdentity(c)
|
|
141
|
+
const account = identityValue(expected)
|
|
142
|
+
const uid = identityValue(expectedUid)
|
|
143
|
+
if (account === undefined && uid === undefined) return c.accessToken
|
|
144
|
+
const now = accountIdOf(c)
|
|
145
|
+
const currentYid = identityValue(c.yid)
|
|
146
|
+
const currentUid = identityValue(c.id)
|
|
147
|
+
const accountWasUid = account !== undefined && (account === uid || account === currentUid)
|
|
148
|
+
const conflict =
|
|
149
|
+
(uid !== undefined && currentUid !== undefined && uid !== currentUid) ||
|
|
150
|
+
(account !== undefined && currentYid !== undefined && account !== currentYid && !accountWasUid)
|
|
151
|
+
const sameAccount =
|
|
152
|
+
(account !== undefined && (account === now || account === currentUid)) ||
|
|
153
|
+
(uid !== undefined && uid === currentUid)
|
|
154
|
+
if (conflict || !sameAccount) {
|
|
155
|
+
throw Object.assign(
|
|
156
|
+
new Error(
|
|
157
|
+
now
|
|
158
|
+
? `LobsterAI 桌面端已切换到其它账号(当前 ${now},本账号 ${expected}),请重新登录本账号`
|
|
159
|
+
: "LobsterAI 桌面端当前登录的账号身份不可读(可能刚切换过),请确认后在桌面端重新登录本账号",
|
|
160
|
+
),
|
|
161
|
+
{ signIn: "expired" },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
99
164
|
return c.accessToken
|
|
100
165
|
}
|