magicrew-auth 0.3.6 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicrew-auth",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -18,7 +18,8 @@ import {
18
18
  fetchWho,
19
19
  fetchOwnInviteCode,
20
20
  } from "../api.mjs"
21
- import { inviteToUse, readSession, writeSession, noteLoginFailure, rollInvite } from "../store.mjs"
21
+ import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, readKnownOrgs, rollInvite } from "../store.mjs"
22
+ import { readLocalCreds } from "../local-browser.mjs"
22
23
  import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
23
24
 
24
25
  export function makeMethods(c) {
@@ -34,6 +35,53 @@ export function makeMethods(c) {
34
35
  : `${c.web}/login`
35
36
  const hostFragment = new URL(c.web).hostname
36
37
 
38
+ // ⓿ 本机浏览器里已经有这个站点的登录态 → 直接接上,不开窗口。
39
+ //
40
+ // 这是「有的人网页登录了、插件里却没多出账号」的正解:用户平时那个浏览器
41
+ // 登录完,凭据就已经落在它的 localStorage 里了 —— 读文件就能拿到,
42
+ // 不用再让他在这个窗口里重登一次。
43
+ //
44
+ // ⚠ 只在这个账号**还没加过**时自动接:否则用户点登录是想加新账号,
45
+ // 自动接上的会是同一个,magpie 侧(同 accountId + 同 uid)只会替换旧账号。
46
+ const local = readLocalCreds(hostFragment)
47
+ if (local && !readKnownOrgs(c.id).includes(local.org)) {
48
+ return {
49
+ url,
50
+ opened: true, // 凭据来自本机浏览器,我们没开页面 —— 别让 magpie 再开一个
51
+ instructions: `已从本机 ${local.browser} 读到 ${c.SHORT} 的登录态,正在验证…`,
52
+ method: "auto",
53
+ async callback() {
54
+ let r
55
+ try {
56
+ // 请求放在 callback 里:authorize 必须立刻返回,而且没被调用时不该打网络
57
+ await fetchPoints(local.jwt, local.org, c.host)
58
+ r = await issueGatewayKey(local.jwt, local.org, c.host)
59
+ } catch (e) {
60
+ // 本机那把失效了:记进「加过」名单,下次点登录就走窗口
61
+ noteOrg(c.id, local.org)
62
+ return {
63
+ type: "failed",
64
+ error: `本机 ${local.browser} 里的登录态已失效(${String(e?.message ?? e)})。再点一次登录会打开窗口;或者先去那个浏览器里重新登录。`,
65
+ }
66
+ }
67
+ writeSession(c.id, local.jwt, local.org)
68
+ noteOrg(c.id, local.org)
69
+ rollInvite(c.id, local.jwt, local.org, c.host).catch(() => {})
70
+ const who = await fetchWho(local.jwt, local.org, c.host).catch(() => null)
71
+ return {
72
+ type: "success",
73
+ access: r.apiKey,
74
+ refresh: r.refreshToken,
75
+ expires: Date.now() + 2 * 3600_000,
76
+ accountId: who?.name || local.org,
77
+ uid: local.org,
78
+ jwt: local.jwt,
79
+ org: local.org,
80
+ }
81
+ },
82
+ }
83
+ }
84
+
37
85
  // 浏览器登录是**可选能力**,按需加载:这个模块用 child_process /
38
86
  // WebSocket / 端口探测,环境依赖最重。静态 import 一旦失败,
39
87
  // 整个插件都加载不起来 —— 连「登录态」那条备选也没了。
@@ -97,6 +145,7 @@ export function makeMethods(c) {
97
145
  await fetchPoints(got.jwt, got.org, c.host)
98
146
  const key = await issueGatewayKey(got.jwt, got.org, c.host)
99
147
  writeSession(c.id, got.jwt, got.org)
148
+ noteOrg(c.id, got.org)
100
149
  // 滚邀请码:把这个账号自己的码存成下一次用的
101
150
  rollInvite(c.id, got.jwt, got.org, c.host).catch(() => {})
102
151
  return {
@@ -170,6 +219,7 @@ export function makeMethods(c) {
170
219
  return { type: "failed", error: String(e?.message ?? e) }
171
220
  }
172
221
  writeSession(c.id, jwt, org)
222
+ noteOrg(c.id, org)
173
223
  rollInvite(c.id, jwt, org, c.host).catch(() => {})
174
224
  // 卡片上显示手机号/邮箱/昵称,取不到就退回组织码
175
225
  const who = await fetchWho(jwt, org, c.host).catch(() => null)
@@ -0,0 +1,163 @@
1
+ // 从**本机浏览器的 localStorage** 里读 magicrew 登录态 —— 不开窗口、不让用户粘任何东西。
2
+ //
3
+ // ═══ 为什么需要这条路 ═══════════════════════════════════════════════
4
+ //
5
+ // magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「用户在网页上
6
+ // 登录成功」这件事,插件原本只能靠「自己起的那个浏览器」知道。于是有一个反复被报的
7
+ // 现象:**用户在自己浏览器里登好了,插件里却没有多出账号** —— 因为那是另一个浏览器。
8
+ //
9
+ // 但凭据其实就躺在本地:Chromium 系(Edge / Chrome / Brave)把 localStorage 明文写在
10
+ //
11
+ // <User Data>/<Default|Profile N>/Local Storage/leveldb/*.log|*.ldb
12
+ //
13
+ // 里,key 是 `_<origin>\x00\x01<name>`,值是 JSON。直接读文件就行,不用 CDP、不用开窗口。
14
+ //
15
+ // 读法:把 leveldb 目录按 latin1 拼成文本 → 以站点 host 出现的位置开一个窗口 →
16
+ // 在同一个窗口里找 `"key":"authorization"` 与 `"key":"organizationCode2?"`。
17
+ // LevelDB 按 key 排序,同一 origin 的记录挨在一起,所以窗口内配对是可靠的
18
+ // (实测本机 Edge:`www.letsmagic.cn` 命中、`www.magicrew.ai` 不命中,不会串)。
19
+ //
20
+ // 成本:本机 Edge 27 MB / 34 ms。全部浏览器扫一遍通常 < 300 ms。
21
+ //
22
+ // ⚠ 只读。不写文件、不改浏览器数据、不碰 cookie。
23
+
24
+ import { homedir } from "node:os"
25
+ import { readdirSync, readFileSync } from "node:fs"
26
+ import { join } from "node:path"
27
+
28
+ /** 同一个 origin 的若干条记录(theme / i18n / userInfo / authorization …)挤在一起,
29
+ * 2000 字节足够覆盖到 organizationCode;太小会漏、太大可能跨到另一个站点 */
30
+ const WINDOW = 2000
31
+
32
+ /** 各平台的浏览器 User Data 根目录;不存在就跳过 */
33
+ function roots() {
34
+ const h = homedir()
35
+ if (process.platform === "win32") {
36
+ const local = process.env.LOCALAPPDATA || join(h, "AppData", "Local")
37
+ return [
38
+ ["Edge", join(local, "Microsoft", "Edge", "User Data")],
39
+ ["Chrome", join(local, "Google", "Chrome", "User Data")],
40
+ ["Brave", join(local, "BraveSoftware", "Brave-Browser", "User Data")],
41
+ ["Chromium", join(local, "Chromium", "User Data")],
42
+ ]
43
+ }
44
+ if (process.platform === "darwin") {
45
+ return [
46
+ ["Chrome", join(h, "Library/Application Support/Google/Chrome")],
47
+ ["Edge", join(h, "Library/Application Support/Microsoft Edge")],
48
+ ["Brave", join(h, "Library/Application Support/BraveSoftware/Brave-Browser")],
49
+ ["Chromium", join(h, "Library/Application Support/Chromium")],
50
+ ]
51
+ }
52
+ return [
53
+ ["Chrome", join(h, ".config/google-chrome")],
54
+ ["Chromium", join(h, ".config/chromium")],
55
+ ["Edge", join(h, ".config/microsoft-edge")],
56
+ ["Brave", join(h, ".config/BraveSoftware/Brave-Browser")],
57
+ ]
58
+ }
59
+
60
+ function profilesOf(root) {
61
+ try {
62
+ return readdirSync(root).filter((n) => n === "Default" || /^Profile \d+$/.test(n))
63
+ } catch {
64
+ return []
65
+ }
66
+ }
67
+
68
+ function leveldbText(root, profile) {
69
+ const dir = join(root, profile, "Local Storage", "leveldb")
70
+ let files
71
+ try {
72
+ files = readdirSync(dir)
73
+ } catch {
74
+ return ""
75
+ }
76
+ let text = ""
77
+ for (const f of files) {
78
+ try {
79
+ text += readFileSync(join(dir, f)).toString("latin1")
80
+ } catch {}
81
+ }
82
+ return text
83
+ }
84
+
85
+ /** jwt 里的 iat / exp;解不出来返回 0 */
86
+ function jwtTimes(jwt) {
87
+ try {
88
+ const body = JSON.parse(Buffer.from(String(jwt).split(".")[1] || "", "base64url").toString("utf8"))
89
+ return { iat: Number(body.iat) || 0, exp: Number(body.exp) || 0 }
90
+ } catch {
91
+ return { iat: 0, exp: 0 }
92
+ }
93
+ }
94
+
95
+ /** LevelDB 里一条记录的前缀:`_https://<origin>\x00\x01<name>` */
96
+ const ORIGIN_RE = /_https?:\/\/([^\x00\x01"\\]{4,80})\x00\x01/g
97
+
98
+ /**
99
+ * 在 host 每一次出现的位置开窗,取窗口内同一 origin 的 jwt × org 组合。
100
+ *
101
+ * ⚠ 必须按 origin 截断:两个站点的记录可能相邻(用户两个都登录过),
102
+ * 2000 字节的窗口会把国际站的 org 配到国内站的 jwt 上。
103
+ * 截断点 = 「下一个**不同站点**的 origin」,同站点的多条记录不截断。
104
+ */
105
+ function credsNear(text, host, win = WINDOW) {
106
+ const bare = host.replace(/^www\./, "")
107
+ const origins = []
108
+ for (const m of text.matchAll(ORIGIN_RE)) origins.push({ host: m[1], at: m.index })
109
+
110
+ const out = []
111
+ let at = -1
112
+ while ((at = text.indexOf(host, at + 1)) !== -1) {
113
+ let stop = Math.min(text.length, at + win)
114
+ for (const o of origins) {
115
+ if (o.at > at && !o.host.includes(bare)) {
116
+ stop = o.at
117
+ break
118
+ }
119
+ }
120
+ const w = text.slice(at, stop)
121
+ // 每把 jwt 配**它自己后面**最近的那个组织码:
122
+ // LevelDB 按 key 排序(authorization < organizationCode),同一批写入的挨在一起;
123
+ // 取「窗口内第一个 org」会在同一站点存过多把凭据时配错。
124
+ for (const m of w.matchAll(/"key":"authorization","value":"(eyJ[^"]+)"/g)) {
125
+ const tail = w.slice(m.index)
126
+ const org = (tail.match(/"key":"organizationCode2?","value":"([^"]+)"/) || [])[1]
127
+ if (org) out.push({ jwt: m[1], org })
128
+ }
129
+ }
130
+ return out
131
+ }
132
+
133
+ /**
134
+ * 读本机浏览器里属于这个站点的登录态。
135
+ *
136
+ * @param {string} hostname 例如 `www.letsmagic.cn`
137
+ * @returns {{jwt:string, org:string, browser:string, profile:string, host:string, iat:number} | null}
138
+ * 取 iat 最新、且未过期的那一把;读不到返回 null(调用方降级到窗口登录)。
139
+ */
140
+ export function readLocalCreds(hostname, { now = Date.now() } = {}) {
141
+ const bare = String(hostname || "").trim().replace(/^www\./, "")
142
+ if (!bare) return null
143
+ const hosts = [hostname, bare, `www.${bare}`].filter((v, i, a) => v && a.indexOf(v) === i)
144
+
145
+ let best = null
146
+ for (const [browser, root] of roots()) {
147
+ for (const profile of profilesOf(root)) {
148
+ const text = leveldbText(root, profile)
149
+ if (!text) continue
150
+ for (const host of hosts) {
151
+ for (const c of credsNear(text, host)) {
152
+ const { iat, exp } = jwtTimes(c.jwt)
153
+ if (!iat) continue
154
+ if (exp && exp * 1000 < now) continue
155
+ if (!best || iat > best.iat) best = { ...c, iat, browser, profile, host }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return best
161
+ }
162
+
163
+ export const _internal = { roots, profilesOf, leveldbText, credsNear, jwtTimes, WINDOW }
package/src/store.mjs CHANGED
@@ -163,6 +163,40 @@ function writeSession(siteId, jwt, org) {
163
163
  } catch {}
164
164
  }
165
165
 
166
+ // ═══ 加过哪些账号(只记 org)═══════════════════════════════════════════
167
+ //
168
+ // 唯一的用途:决定「本机浏览器里读到的登录态要不要自动接上」。
169
+ //
170
+ // 用户点「登录」多数是想加**新**账号;如果本机浏览器里那把属于已经加过的账号,
171
+ // 自动接上只会得到「同一个账号」(magpie 侧同 accountId+uid → 替换,不新增),
172
+ // 看起来又像「登录了却没加账号」。所以已经在名单里的 org,一律改走窗口登录。
173
+ //
174
+ // 记不上也无所谓:最坏是自动接上一个重复账号,magpie 会替换它。
175
+
176
+ function orgsPath(siteId) {
177
+ return join(cacheDir(), `magicrew-orgs-${siteId}.json`)
178
+ }
179
+
180
+ function readKnownOrgs(siteId) {
181
+ try {
182
+ const v = JSON.parse(readFileSync(orgsPath(siteId), "utf8"))
183
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : []
184
+ } catch {
185
+ return []
186
+ }
187
+ }
188
+
189
+ function noteOrg(siteId, org) {
190
+ if (!org || typeof org !== "string") return
191
+ try {
192
+ const list = readKnownOrgs(siteId)
193
+ if (list.includes(org)) return
194
+ list.push(org)
195
+ const f = orgsPath(siteId)
196
+ mkdirSync(dirname(f), { recursive: true })
197
+ writeFileSync(f, JSON.stringify(list.slice(-50)))
198
+ } catch {}
199
+ }
166
200
 
167
201
  export {
168
202
  AUTHOR_INVITE,
@@ -172,7 +206,10 @@ export {
172
206
  inviteToUse,
173
207
  loginLogPath,
174
208
  noteLoginFailure,
209
+ noteOrg,
210
+ orgsPath,
175
211
  readInvite,
212
+ readKnownOrgs,
176
213
  readSession,
177
214
  readSnapshot,
178
215
  sessionPath,