magicrew-auth 0.3.26 → 0.3.29

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.
@@ -1,16 +1,26 @@
1
1
  // ② auth.methods[] —— 登录
2
2
  //
3
- // 两条路,按可靠性排:
3
+ // 四条路,按可靠性排:
4
4
  //
5
- // ① 浏览器登录(默认,零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
5
+ // ① 手机号验证码(零浏览器):识图模型过旋转验证 → 发短信 → 用户填码。
6
+ // ② 邮箱验证码:同一条链,只有标识字段和两个端点不同(见下面 makeCodeMethod)。
7
+ // ③ 浏览器登录(默认零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
6
8
  // 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
7
9
  // 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
8
- // ② 登录态(JWT + 组织码):手填两个字段,兜底用。
10
+ // ④ 登录态(JWT + 组织码):手填两个字段,兜底用。
9
11
  //
10
- // ⚠ 两条铁律:
12
+ // ⚠ 三条铁律:
11
13
  // · authorize() 必须立刻返回 —— 真正等用户的是 callback(),死等会和 magpie 互锁
12
14
  // · opened: true 的语义是「我自己已经开过页面了,你别再开」;
13
15
  // 只返回 url 让它去开的,**不许**传 opened
16
+ // · authorize() 里失败只能抛异常,**不能** return { type: "failed" } ——
17
+ // magpie 把它的返回值当「登录流程对象」,会去调 `.callback()`,于是崩在一句
18
+ // `callback is not a function` 上(实测触发路径:callback 失败 → 用户点「重试」
19
+ // → 重新调 authorize → 又走到失败分支 → 崩)
20
+ //
21
+ // ⚠ 数组顺序 = magpie 登录方式列表里的顺序,所以**按站点分**:
22
+ // 国际站(magicrew-intl)邮箱在前 —— 那边用户大多是邮箱注册的;
23
+ // 国内站手机号在前。
14
24
 
15
25
  import {
16
26
  issueGatewayKey,
@@ -22,7 +32,7 @@ import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, read
22
32
  import { readLocalCreds } from "../local-browser.mjs"
23
33
  import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
24
34
  import { readVisionModel } from "../vision.mjs"
25
- import { finishSmsLogin, startSmsLogin } from "../headless.mjs"
35
+ import { finishCodeLogin, startCodeLogin } from "../headless.mjs"
26
36
 
27
37
  /**
28
38
  * 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
@@ -46,6 +56,16 @@ function kickoffFirstTask(c, jwt, org) {
46
56
  * `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
47
57
  * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
48
58
  *
59
+ * 后两个参数只影响**卡片上显示的名字**,但选错会让同一个号在两条登录路上
60
+ * 算出不同的 accountId,magpie 就把同一个号加成两条(真机踩过)。三档优先级:
61
+ *
62
+ * preferredAccountId → who.name(服务端给的手机号/邮箱/昵称)→ fallbackAccountId → 组织码
63
+ *
64
+ * · 手机号验证码那条传 preferred —— 服务端只回打码号(`189****051`),
65
+ * 得由我们自己把它固定成同一个字符串,两条路才一致
66
+ * · 邮箱验证码那条传 fallback —— 邮箱不需要打码;who 成功时优先用 who,
67
+ * 这样和浏览器登录那条路算出来的是同一个
68
+ *
49
69
  * ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
50
70
  * 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
51
71
  * 所以「第一个验得过的」实际就是「列表第一个」。
@@ -54,7 +74,7 @@ function kickoffFirstTask(c, jwt, org) {
54
74
  * 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
55
75
  * 这是已知限制,不是 bug。
56
76
  */
57
- async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
77
+ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null, fallbackAccountId = null) {
58
78
  const candidates = [org, ...orgs.filter((o) => o && o !== org)]
59
79
  let lastErr = null
60
80
  let picked = null
@@ -85,10 +105,7 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
85
105
  // 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
86
106
  // 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
87
107
  // 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
88
- //
89
- // 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
90
- // `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
91
- const accountId = preferredAccountId || who?.name || picked
108
+ const accountId = preferredAccountId || who?.name || fallbackAccountId || picked
92
109
  return {
93
110
  type: "success",
94
111
  access: key.apiKey,
@@ -101,161 +118,235 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
101
118
  }
102
119
  }
103
120
 
104
- export function makeMethods(c) {
105
- return [
106
- {
107
- // 手机号 + 短信验证码,**不开浏览器**。
108
- //
109
- // 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图。现在的做法:
110
- // 用 magpie 设置里用户自己选的识图模型(settings.json 的 vision)
111
- // 判断「主体上方向指向钟面几点」,再换算成拖动角度 —— 实测 120° 的图判出 122°,
112
- // 而服务端容差 ±10°,够用。
113
- //
114
- // 没配识图模型时不在这里瞎猜(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
115
- // 而是让这条方法直接报错并指路 —— 详见下面那段说明。
116
- type: "oauth",
117
- label: `${c.SHORT} 手机号验证码`,
118
- prompts: [
119
- {
120
- type: "text",
121
- key: "phone",
122
- message: "手机号",
123
- placeholder: "13800138000",
124
- validate: (v) =>
125
- /^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
126
- },
127
- ],
128
- async authorize(inputs) {
129
- const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
130
- // ⚠ authorize() 里**不能**返回 { type: "failed" }:magpie 把这里的返回值当作
131
- // 「登录流程对象」,会去调它的 `.callback()` —— 少了这个函数就是一句
132
- // `callback is not a function`,用户只看到莫名其妙的报错。
133
- // 实测触发路径:callback 里失败 → magpie 显示「重试」 → 点重试会**重新调
134
- // authorize** → 这次走到这条失败分支 → 返回没有 callback 的对象 → 崩。
135
- // ⇒ authorize 里失败一律抛异常。
136
- if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
137
-
138
- const vision = readVisionModel()
139
- const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
140
-
141
- if (!vision) {
142
- // 没有识图模型 → 回退到浏览器:打不开就不硬撑。
143
- //
144
- // 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
145
- // 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
146
- // 等不到猜中,先被限流。所以老老实实让用户转那一下。
147
- let browser = null
148
- let why = ""
149
- try {
150
- browser = await import("../browser-login.mjs")
151
- } catch (e) {
152
- why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
153
- noteLoginFailure(c.id, why)
154
- }
121
+ /** 邮箱的粗校验。别搬 RFC 那个怪物正则 —— 服务端还会再验一次 */
122
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
123
+
124
+ /**
125
+ * 「验证码登录」——手机号与邮箱共用一份实现。
126
+ *
127
+ * 两条链的协议**完全一样**:取图 → 识图判角度 → 过旋转验证 → 发码 → 用户填码 →
128
+ * 换登录态。差异只有三处,而且都已收敛进 captcha.mjs / headless.mjs 按 ident 分派:
129
+ *
130
+ * · 问用户要什么:11 位手机号 / 邮箱
131
+ * · 发码端点:`/v4/user/send-sms` / `/v4/user/send-email`
132
+ * · 换登录态:`/v4/user/login`(验证码字段叫 `code`)/
133
+ * `/v4/user/login-email`(字段叫 `captcha`)
134
+ *
135
+ * 所以这里只写一份、传 kind。哪天被复制成两份,下次改超时或改文案必然只改一份。
136
+ *
137
+ * 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图:用 magpie 设置里用户
138
+ * 自己选的识图模型(settings.json 的 vision)判「主体上方向指向钟面几点」,再换算
139
+ * 成拖动角度 —— 实测 120° 的图判出 122°,而服务端容差 ±10°,够用。
140
+ *
141
+ * 没配识图模型时**不在这里瞎猜**(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
142
+ * 而是回退到浏览器登录,让用户自己转那一下。
143
+ *
144
+ * @param {object} c 站点闭包(id / SHORT / web / host)
145
+ * @param {"mobile"|"email"} kind
146
+ */
147
+ function makeCodeMethod(c, kind) {
148
+ const isEmail = kind === "email"
149
+ const what = isEmail ? "邮箱" : "手机号"
150
+ /** 官方登录页上那个 tab 的名字 —— 回退到浏览器时要说清楚点哪个 */
151
+ const tab = isEmail ? "邮箱登录(Email)" : "手机登录(Phone)"
155
152
 
156
- const inviteCode = inviteToUse(c.id)
157
- const url = inviteCode
158
- ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
159
- : `${c.web}/login`
160
- const hostFragment = new URL(c.web).hostname
153
+ /** 用户填的那一项 → 之后每个请求都要带的标识字段 */
154
+ const identOf = (inputs) =>
155
+ isEmail
156
+ ? { email: String(inputs?.email || "").trim() }
157
+ : { mobile: String(inputs?.phone || "").replace(/[\s-]/g, ""), stateCode: "+86" }
161
158
 
162
- const session = browser
163
- ? await browser.launchLoginBrowser(url).catch((e) => {
164
- why = String(e?.message ?? e).slice(0, 300)
165
- noteLoginFailure(c.id, `launch: ${why}`)
166
- return null
167
- })
168
- : null
159
+ /**
160
+ * 给用户看的那个标识。
161
+ * 手机号打码(服务端自己也是这么回的),邮箱原样 —— 邮箱没有打码这回事,
162
+ * 而且它正好是浏览器登录那条路上 `who.name` 会给出的字符串。
163
+ */
164
+ const displayOf = (ident) =>
165
+ isEmail ? ident.email : String(ident.mobile).replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
169
166
 
170
- return {
171
- url,
172
- ...(session ? { opened: true } : {}),
173
- instructions: session
174
- ? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
175
- `在里面选「手机登录」,填 ${masked},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
176
- `(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填手机号和验证码。)`
177
- : `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
178
- `登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
179
- method: "auto",
180
- async callback() {
181
- let got = null
182
- if (session && browser) {
183
- got = await browser.waitForCreds(session, hostFragment).catch(() => null)
184
- await browser.closeSession(session).catch(() => {})
185
- }
186
- if (!got && !session) {
187
- got = await pollClipboard({ skip: "" }).catch(() => null)
188
- }
189
- if (!got) {
190
- return {
191
- type: "failed",
192
- error: session
193
- ? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
194
- : "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
195
- }
196
- }
197
- try {
198
- if (!got.org) throw new Error("只拿到 JWT,没有组织码")
199
- return await completeSignIn(c, got.jwt, got.org)
200
- } catch (e) {
201
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
202
- }
203
- },
167
+ const looksInvalid = (ident) => (isEmail ? !EMAIL_RE.test(ident.email) : !/^1\d{10}$/.test(ident.mobile))
168
+
169
+ return {
170
+ type: "oauth",
171
+ label: `${c.SHORT} ${what}验证码`,
172
+ prompts: [
173
+ isEmail
174
+ ? {
175
+ type: "text",
176
+ key: "email",
177
+ message: "邮箱",
178
+ placeholder: "you@example.com",
179
+ validate: (v) =>
180
+ EMAIL_RE.test(String(v || "").trim()) ? undefined : "填一个完整邮箱,例如 you@example.com",
204
181
  }
205
- }
182
+ : {
183
+ type: "text",
184
+ key: "phone",
185
+ message: "手机号",
186
+ placeholder: "13800138000",
187
+ validate: (v) =>
188
+ /^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
189
+ },
190
+ ],
191
+ async authorize(inputs) {
192
+ const ident = identOf(inputs)
193
+ // ⚠ 这里是 authorize,失败只能抛异常,不能返回 { type: "failed" }(见文件头铁律)
194
+ if (looksInvalid(ident)) throw new Error(`${what}不完整`)
206
195
 
207
- // 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
208
- // ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
209
- // `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
210
- // 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
211
- const inviteCode = inviteToUse(c.id)
212
- const loginUrl = inviteCode
213
- ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
214
- : `${c.web}/login`
196
+ const shown = displayOf(ident)
197
+
198
+ // 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
199
+ // ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
200
+ // `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
201
+ // 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
202
+ const inviteCode = inviteToUse(c.id)
203
+ const loginUrl = inviteCode
204
+ ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
205
+ : `${c.web}/login`
215
206
 
216
- // 发短信这一步最慢(过验证最多试 3 张图、每张问两遍模型)。
217
- // 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
218
- let sent
207
+ /**
208
+ * 需要过图、但用户没配识图模型 → 回退到浏览器,让他自己转那一下。
209
+ *
210
+ * ⚠ 只有**这个号还没注册**时才该走到这里:已注册的号服务端根本不发旋转图,
211
+ * `startCodeLogin` 会直接发码(见 headless.mjs 里那段说明)。
212
+ *
213
+ * 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
214
+ * 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
215
+ * 等不到猜中,先被限流。所以老老实实让用户转那一下。
216
+ */
217
+ async function browserFallback() {
218
+ const url = loginUrl
219
+ const hostFragment = new URL(c.web).hostname
220
+ let browser = null
221
+ let why = ""
219
222
  try {
220
- sent = await startSmsLogin(c, { mobile: phone })
223
+ browser = await import("../browser-login.mjs")
221
224
  } catch (e) {
222
- // 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
223
- // 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
224
- throw new Error(String(e?.message ?? e).slice(0, 500))
225
+ why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
226
+ noteLoginFailure(c.id, why)
225
227
  }
226
228
 
229
+ const session = browser
230
+ ? await browser.launchLoginBrowser(url).catch((e) => {
231
+ why = String(e?.message ?? e).slice(0, 300)
232
+ noteLoginFailure(c.id, `launch: ${why}`)
233
+ return null
234
+ })
235
+ : null
236
+
227
237
  return {
228
- url: loginUrl,
229
- instructions:
230
- `验证码已发送到 ${masked}。\n` +
231
- `旋转验证由识图模型(${vision})自动通过,试了 ${sent.rounds} 张图。\n` +
232
- `把收到的 6 位验证码填进下面的框就行。`,
233
- method: "code",
234
- // ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
235
- // opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
236
- // 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
237
- // 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
238
- opened: true,
239
- async callback(code) {
240
- const c0 = String(code || "").replace(/\D/g, "")
241
- if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
242
- let creds
243
- try {
244
- creds = await finishSmsLogin(c, { mobile: phone, code: c0, redirect: loginUrl })
245
- } catch (e) {
246
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
238
+ url,
239
+ ...(session ? { opened: true } : {}),
240
+ instructions: session
241
+ ? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
242
+ `在里面选「${tab}」,填 ${shown},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
243
+ `(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填${what}和验证码。)`
244
+ : `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
245
+ `登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
246
+ method: "auto",
247
+ async callback() {
248
+ let got = null
249
+ if (session && browser) {
250
+ got = await browser.waitForCreds(session, hostFragment).catch(() => null)
251
+ await browser.closeSession(session).catch(() => {})
252
+ }
253
+ if (!got && !session) {
254
+ got = await pollClipboard({ skip: "" }).catch(() => null)
255
+ }
256
+ if (!got) {
257
+ return {
258
+ type: "failed",
259
+ error: session
260
+ ? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
261
+ : "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
262
+ }
247
263
  }
248
264
  try {
249
- // 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
250
- // 这个字符串,两边一致才不会把同一个号加成两条
251
- return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
265
+ if (!got.org) throw new Error("只拿到 JWT,没有组织码")
266
+ return await completeSignIn(c, got.jwt, got.org)
252
267
  } catch (e) {
253
- return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
268
+ return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
254
269
  }
255
270
  },
256
271
  }
257
- },
272
+ }
273
+
274
+ // 发码这一步最慢(要给新号过验证时最多试 4 张图、每张问两遍模型)。
275
+ // 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
276
+ let sent
277
+ try {
278
+ sent = await startCodeLogin(c, ident)
279
+ } catch (e) {
280
+ if (e?.code === "no-vision-model") {
281
+ // ⚡ 只有这一种情况才回退到浏览器:这个号**还没注册**(服务端给了旋转图),
282
+ // 而用户没配识图模型。已注册的号走不到这里 —— 服务端不出图。
283
+ return browserFallback()
284
+ }
285
+ // 其余一律抛:authorize 里 return failed 会让 magpie 崩在 `.callback()`
286
+ throw new Error(String(e?.message ?? e).slice(0, 500))
287
+ }
288
+
289
+ return {
290
+ url: loginUrl,
291
+ instructions: sent.skippedCaptcha
292
+ ? `验证码已发送到 ${shown}。\n` +
293
+ `这个号服务端那边已经注册过,旋转验证直接放行 —— 这一环跳过了,识图模型这次也用不上。\n` +
294
+ `把收到的 6 位验证码填进下面的框就行。`
295
+ : `验证码已发送到 ${shown}。\n` +
296
+ `旋转验证由识图模型(${readVisionModel()})自动通过,试了 ${sent.rounds} 张图。\n` +
297
+ `把收到的 6 位验证码填进下面的框就行。`,
298
+ method: "code",
299
+ // ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
300
+ // opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
301
+ // 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
302
+ // 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
303
+ opened: true,
304
+ async callback(code) {
305
+ const c0 = String(code || "").replace(/\D/g, "")
306
+ if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
307
+ let creds
308
+ try {
309
+ creds = await finishCodeLogin(c, { ...ident, code: c0, redirect: loginUrl })
310
+ } catch (e) {
311
+ return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
312
+ }
313
+ try {
314
+ // 手机号:把打码号钉成 accountId(浏览器登录那条路拿到的也是这个字符串);
315
+ // 邮箱:优先让 who 决定(那才是浏览器登录会给出的一致值),who 挂了才退回邮箱
316
+ return await completeSignIn(
317
+ c,
318
+ creds.jwt,
319
+ creds.org,
320
+ creds.orgs,
321
+ isEmail ? null : shown,
322
+ isEmail ? ident.email : null,
323
+ )
324
+ } catch (e) {
325
+ return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
326
+ }
327
+ },
328
+ }
258
329
  },
330
+ }
331
+ }
332
+
333
+ export function makeMethods(c) {
334
+ // 顺序 = magpie 登录方式列表里的顺序。
335
+ //
336
+ // ⚠ **邮箱验证码只有国际站有**,国内站不给。实测两件事:
337
+ // ① www.letsmagic.cn/login 的登录方式只有 微信 / 苹果 / 密码 / 手机 / 私有部署,
338
+ // **没有邮箱**(国际站 www.magicrew.ai/login 才有 `switch-to-email`);
339
+ // ② 同一个发信请求,国内站 `/v4/user/send-email` 回 `2000 服务异常,请联系管理员`,
340
+ // 国际站回 `1000 请求成功`。
341
+ // 国内站既然没有邮箱入口,也就不存在邮箱注册的账号 —— 把这条方法摆出来,
342
+ // 只会让用户填完邮箱撞一个看不懂的错。
343
+ const codeMethods =
344
+ c.id === "magicrew-intl"
345
+ ? [makeCodeMethod(c, "email"), makeCodeMethod(c, "mobile")]
346
+ : [makeCodeMethod(c, "mobile")]
347
+
348
+ return [
349
+ ...codeMethods,
259
350
 
260
351
  {
261
352
  type: "oauth",
@@ -3,24 +3,31 @@
3
3
  // magpie 在 api_key 到期前(refreshLead)调一次,**返回的字段合并进旧账号**,
4
4
  // 不是整体替换(client.auth.set 才是替换,两者不同)。
5
5
  //
6
- // ⚠ 一次性 refresh_token 必须串行 —— magpie 已按账号串行化,插件里别再并发;
6
+ // ⚠ 一次性 refresh_token 必须串行 —— magpie 已按账号串行化;插件里
7
+ // renewViaStore 与 ensureKey 共用 renewOnce 的同一把锁,也不会并发。
7
8
  // 服务端每次换完会轮换,必须用返回的新值。
8
9
 
9
- import { refreshGatewayKey } from "../api.mjs"
10
+ import { renewViaStore } from "../keys.mjs"
10
11
 
11
12
  export function makeRefresh(c) {
12
13
  return async (auth) => {
13
- if (!auth?.refresh) return undefined
14
+ // 不要求 auth.refresh 存在:refresh_token 可能只躺在 key-store 里
15
+ //(0.3.29 之前 ensureKey 续过、magpie 那份已经轮换废了)。由 renewViaStore
16
+ // 两处都试,这里先别把它挡掉。
17
+ if (!auth) return undefined
14
18
  try {
15
- const k = await refreshGatewayKey(auth.refresh, c.host)
19
+ // ⚠ 必须走 store —— 由它统一持有 refresh_token 并把新 key 写回。
20
+ // 自己再调一次 refreshGatewayKey 会让两条路径各存一把,
21
+ // 哪把还有效本地无法预判,请求侧就会 403「token无效」(2026-10-29 实测)。
22
+ const k = await renewViaStore(c, auth)
16
23
  return {
17
24
  access: k.apiKey,
18
- // 服务端每次 PUT 都轮换 refresh_token,必须用新的
25
+ // 服务端每次换完都轮换 refresh_token,必须用返回的新值
19
26
  refresh: k.refreshToken ?? auth.refresh,
20
- expires: Date.now() + 2 * 3600_000,
27
+ expires: k.expiresAt || Date.now() + 2 * 3600_000,
21
28
  }
22
29
  } catch (e) {
23
- if (/invalid|expired|401|403/i.test(String(e?.message))) {
30
+ if (/invalid|expired|401|403|token/i.test(String(e?.message))) {
24
31
  throw Object.assign(new Error("登录已失效,请重新登录"), { signIn: "expired" })
25
32
  }
26
33
  throw e