magicrew-auth 0.3.16 → 0.3.23

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,93 +1,178 @@
1
- // ④ auth.loader(getAuth) —— 请求接管
2
- //
3
- // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
- // 这是唯一能改请求体的地方。本项目用它干两件事:
5
- //
6
- // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
- // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
- // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
- //
10
- // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
- // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
-
13
- import { ensureKey, accountOf } from "../keys.mjs"
14
-
15
- export function makeLoader(c) {
16
- return async (getAuth) => {
17
- const auth = await getAuth()
18
- if (!auth) return {}
19
- return {
20
- baseURL: c.BASE,
21
- apiKey: auth.access,
22
- headers: { "user-agent": "MagicrewClient/1.0" },
23
- async fetch(input, init) {
24
- const fresh = await getAuth()
25
- if (!fresh) throw new Error(`${c.SHORT} 未登录`)
26
-
27
- // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
28
- const key = await ensureKey(c, getAuth)
29
- if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
30
- const account = accountOf(fresh)
31
-
32
- const req = input instanceof Request ? input : null
33
- const url =
34
- typeof input === "string"
35
- ? input
36
- : input instanceof URL
37
- ? input.toString()
38
- : (req?.url ?? "")
39
-
40
- const incoming = new Headers(init?.headers ?? req?.headers ?? {})
41
- const headers = {
42
- authorization: `Bearer ${key.apiKey}`,
43
- "content-type": "application/json",
44
- }
45
- if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
46
-
47
- for (const [k, v] of incoming) {
48
- if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
49
- headers[k] = v
50
- }
51
-
52
- let body = init?.body
53
- if (body === undefined && req) body = await req.text()
54
-
55
- // 改写两处请求体,都为实测出来的:
56
- //
57
- // ① max_tokens 抬到 512
58
- // 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
59
- // 512 → 正常出话(reasoning 208 / total 233)
60
- //
61
- // ② 流式请求补 stream_options.include_usage
62
- // magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
63
- // 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
64
- // magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
65
- // 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
66
- // 控制台里看到的就是:模型请求重试 (3/5) · Stream ended without finish_reason
67
- if (typeof body === "string" && body.includes('"stream"')) {
68
- try {
69
- const j = JSON.parse(body)
70
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
71
- if (j.stream === true && !j.stream_options) {
72
- j.stream_options = { include_usage: true }
73
- }
74
- body = JSON.stringify(j)
75
- } catch {}
76
- } else if (typeof body === "string" && body.includes('"max_tokens"')) {
77
- try {
78
- const j = JSON.parse(body)
79
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
80
- body = JSON.stringify(j)
81
- } catch {}
82
- }
83
-
84
- return fetch(url, {
85
- method: init?.method ?? "POST",
86
- headers,
87
- body,
88
- signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
89
- })
90
- },
91
- }
92
- }
93
- }
1
+ // ④ auth.loader(getAuth) —— 请求接管
2
+ //
3
+ // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
+ // 这是唯一能改请求体的地方。本项目用它干两件事:
5
+ //
6
+ // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
+ // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
+ // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
+ //
10
+ // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
+ // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
+
13
+ import { ensureKey, accountOf } from "../keys.mjs"
14
+
15
+ /**
16
+ * 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
17
+ *
18
+ * 上游只认布尔值,收到任何对象都回 HTTP 400
19
+ * `{"error":{"message":"模型响应失败","code":4017}}`,整个请求被拒。
20
+ * 实测(2026-10-28,glm-5.3-flash 与 deepseek-v4.1-flash **两个模型完全一致**,
21
+ * 同一把 key、同一份请求、只改这一个字段):
22
+ *
23
+ * additionalProperties: 缺省 → 200
24
+ * additionalProperties: false → 200
25
+ * additionalProperties: true → 200
26
+ * additionalProperties: {} → 400 code 4017
27
+ * additionalProperties: {type:"string"} → 400 code 4017
28
+ * additionalProperties: {type:"string",description:"x"} → 400 code 4017
29
+ *
30
+ * ⚠ 注意**不只是空对象**。有约束的 schema 形态(`{type:"string"}`)一样被拒 ——
31
+ * 这一点比"上游只认 true/false"更进一步:它根本不支持 schema 形态。
32
+ * 所以这里一律换成 `true`,属于**降级放宽**:
33
+ *
34
+ * · `{}` → `true` 语义等价(都是"任意,不加约束")
35
+ * · `{type:"string"}` → `true` **不等价** —— 丢掉了"额外属性必须是 string"的约束
36
+ *
37
+ * 为什么接受这个不等价:上游无法表达它,保留原值就是整个请求失败。两害相权,
38
+ * 让工具调用能跑(可能多出未声明的字段)比让 agent 完全不可用要好。方向也安全 ——
39
+ * 是放宽不是收紧,不会让原本合法的调用被拒。
40
+ *
41
+ * 命中来源:ZCode / Claude Code 系客户端生成 schema 时爱写 `{}`;
42
+ * 一次真实请求里 54 个工具中的 `ExitPlanMode` 与 `CreateWorkflow` 就这么写。
43
+ * 客户端没写错(按规范 `{}` 与 `true` 等价),是上游兼容性不足,插件这边兜住。
44
+ *
45
+ * @returns {boolean} 有没有改到
46
+ */
47
+ export function loosenAdditionalProperties(node) {
48
+ if (!node || typeof node !== "object") return false
49
+ if (Array.isArray(node)) {
50
+ let any = false
51
+ for (const n of node) if (loosenAdditionalProperties(n)) any = true
52
+ return any
53
+ }
54
+ let changed = false
55
+ for (const [k, v] of Object.entries(node)) {
56
+ if (k === "additionalProperties") {
57
+ // 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
58
+ if (typeof v !== "boolean") {
59
+ node[k] = true
60
+ changed = true
61
+ }
62
+ continue
63
+ }
64
+ if (v && typeof v === "object" && loosenAdditionalProperties(v)) changed = true
65
+ }
66
+ return changed
67
+ }
68
+
69
+ /**
70
+ * 改写发往上游的请求体。**一次 parse,三处改写都在这**。
71
+ *
72
+ * 三处都是实测出来的,不是猜的:
73
+ *
74
+ * ① max_tokens 抬到 512
75
+ * 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
76
+ * 512 → 正常出话(reasoning 208 / total 233)
77
+ *
78
+ * ② 流式请求补 stream_options.include_usage
79
+ * magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
80
+ * 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
81
+ * magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
82
+ * 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
83
+ *
84
+ * ③ tools 里的 additionalProperties: {} → true(见 looser 的注释)
85
+ *
86
+ * 没改到时返回原始字符串(省一次序列化 —— 54 个工具的请求体不小)。
87
+ *
88
+ * @param {unknown} raw
89
+ * @returns {unknown} 改了就是新字符串,没改就原样返回
90
+ */
91
+ export function rewriteRequestBody(raw) {
92
+ if (typeof raw !== "string") return raw
93
+ // 粗筛:三个关键字一个都没有,就不必 parse
94
+ if (!/"stream"|"max_tokens"|"tools"/.test(raw)) return raw
95
+
96
+ let j
97
+ try {
98
+ j = JSON.parse(raw)
99
+ } catch {
100
+ return raw
101
+ }
102
+ if (!j || typeof j !== "object") return raw
103
+
104
+ let changed = false
105
+
106
+ if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) {
107
+ j.max_tokens = 512
108
+ changed = true
109
+ }
110
+
111
+ if (j.stream === true && !j.stream_options) {
112
+ j.stream_options = { include_usage: true }
113
+ changed = true
114
+ }
115
+
116
+ if (Array.isArray(j.tools)) {
117
+ for (const t of j.tools) {
118
+ const p = t?.function?.parameters
119
+ if (p && typeof p === "object" && loosenAdditionalProperties(p)) changed = true
120
+ }
121
+ }
122
+
123
+ return changed ? JSON.stringify(j) : raw
124
+ }
125
+
126
+ export function makeLoader(c) {
127
+ return async (getAuth) => {
128
+ const auth = await getAuth()
129
+ if (!auth) return {}
130
+ return {
131
+ baseURL: c.BASE,
132
+ apiKey: auth.access,
133
+ headers: { "user-agent": "MagicrewClient/1.0" },
134
+ async fetch(input, init) {
135
+ const fresh = await getAuth()
136
+ if (!fresh) throw new Error(`${c.SHORT} 未登录`)
137
+
138
+ // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
139
+ const key = await ensureKey(c, getAuth)
140
+ if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
141
+ const account = accountOf(fresh)
142
+
143
+ const req = input instanceof Request ? input : null
144
+ const url =
145
+ typeof input === "string"
146
+ ? input
147
+ : input instanceof URL
148
+ ? input.toString()
149
+ : (req?.url ?? "")
150
+
151
+ const incoming = new Headers(init?.headers ?? req?.headers ?? {})
152
+ const headers = {
153
+ authorization: `Bearer ${key.apiKey}`,
154
+ "content-type": "application/json",
155
+ }
156
+ if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
157
+
158
+ for (const [k, v] of incoming) {
159
+ if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
160
+ headers[k] = v
161
+ }
162
+
163
+ let body = init?.body
164
+ if (body === undefined && req) body = await req.text()
165
+
166
+ // 三处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
167
+ body = rewriteRequestBody(body)
168
+
169
+ return fetch(url, {
170
+ method: init?.method ?? "POST",
171
+ headers,
172
+ body,
173
+ signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
174
+ })
175
+ },
176
+ }
177
+ }
178
+ }
@@ -21,6 +21,8 @@ import {
21
21
  import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, readKnownOrgs, rollInvite } from "../store.mjs"
22
22
  import { readLocalCreds } from "../local-browser.mjs"
23
23
  import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
24
+ import { readVisionModel } from "../vision.mjs"
25
+ import { finishSmsLogin, startSmsLogin } from "../headless.mjs"
24
26
 
25
27
  /**
26
28
  * 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
@@ -35,8 +37,226 @@ function kickoffFirstTask(c, jwt, org) {
35
37
  .catch(() => {})
36
38
  }
37
39
 
40
+ /**
41
+ * 登录拿到 jwt + org 之后的统一收尾。
42
+ *
43
+ * 无头登录和浏览器登录最后都落到这里,保证「验证一次 / 建 key / 落 session /
44
+ * 记组织 / 滚邀请码 / 补首任务 / 取昵称」这一串只有一份实现。
45
+ *
46
+ * `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
47
+ * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
48
+ *
49
+ * ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
50
+ * 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
51
+ * 所以「第一个验得过的」实际就是「列表第一个」。
52
+ * 真正的区别在浏览器登录那条路:它直接读 localStorage 里用户**当前选中**的那个。
53
+ * 服务端虽然给了 `is_current` 字段,但实测恒为 false(带不带 organization-code
54
+ * 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
55
+ * 这是已知限制,不是 bug。
56
+ */
57
+ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
58
+ const candidates = [org, ...orgs.filter((o) => o && o !== org)]
59
+ let lastErr = null
60
+ let picked = null
61
+
62
+ for (const o of candidates) {
63
+ try {
64
+ // 真的验一次:读积分能过,说明 JWT + 组织码都对
65
+ await fetchPoints(jwt, o, c.host)
66
+ picked = o
67
+ break
68
+ } catch (e) {
69
+ lastErr = e
70
+ }
71
+ }
72
+ if (!picked) throw lastErr ?? new Error("没有可用的组织码")
73
+
74
+ const key = await issueGatewayKey(jwt, picked, c.host)
75
+ writeSession(c.id, jwt, picked)
76
+ noteOrg(c.id, picked)
77
+ // 滚邀请码:把这个账号自己的码存成下一次用的
78
+ await rollInvite(c.id, jwt, picked, c.host)
79
+ kickoffFirstTask(c, jwt, picked)
80
+ const who = await fetchWho(jwt, picked, c.host).catch(() => null)
81
+
82
+ // ⚠ `accountId` 决定 magpie 是「替换」还是「新增」—— 同一个账号在两条登录路上
83
+ // 必须算出同一个字符串,否则就会出现「同一个号被加了两遍」。
84
+ //
85
+ // 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
86
+ // 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
87
+ // 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
88
+ //
89
+ // 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
90
+ // `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
91
+ const accountId = preferredAccountId || who?.name || picked
92
+ return {
93
+ type: "success",
94
+ access: key.apiKey,
95
+ refresh: key.refreshToken,
96
+ expires: Date.now() + 2 * 3600_000,
97
+ accountId,
98
+ uid: picked,
99
+ jwt,
100
+ org: picked,
101
+ }
102
+ }
103
+
38
104
  export function makeMethods(c) {
39
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
+ }
155
+
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
161
+
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
169
+
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
+ },
204
+ }
205
+ }
206
+
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`
215
+
216
+ // 发短信这一步最慢(过验证最多试 3 张图、每张问两遍模型)。
217
+ // 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
218
+ let sent
219
+ try {
220
+ sent = await startSmsLogin(c, { mobile: phone })
221
+ } catch (e) {
222
+ // 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
223
+ // 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
224
+ throw new Error(String(e?.message ?? e).slice(0, 500))
225
+ }
226
+
227
+ 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) }
247
+ }
248
+ try {
249
+ // 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
250
+ // 这个字符串,两边一致才不会把同一个号加成两条
251
+ return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
252
+ } catch (e) {
253
+ return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
254
+ }
255
+ },
256
+ }
257
+ },
258
+ },
259
+
40
260
  {
41
261
  type: "oauth",
42
262
  label: `${c.SHORT} 浏览器登录`,
@@ -215,8 +435,9 @@ export function makeMethods(c) {
215
435
  async authorize(inputs) {
216
436
  const jwt = String(inputs?.jwt || "").trim().replace(/^Bearer\s+/i, "")
217
437
  const org = String(inputs?.org || "").trim()
218
- if (!jwt.startsWith("eyJ")) return { type: "failed", error: "不是 JWT" }
219
- if (!org) return { type: "failed", error: "没填组织码" }
438
+ // 同一条规矩:authorize 里失败只抛异常,不返回 { type: "failed" }
439
+ if (!jwt.startsWith("eyJ")) throw new Error("不是 JWT")
440
+ if (!org) throw new Error("没填组织码")
220
441
 
221
442
  const result = (async () => {
222
443
  await fetchPoints(jwt, org, c.host)