@magpie-community/opencode-trae-auth 0.2.9 → 0.2.10

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.
Files changed (3) hide show
  1. package/README.md +17 -0
  2. package/index.mjs +89 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,23 @@ authorization page: `device_id` (19 digits) and `machine_id` (32 hex). Every
46
46
  request then sends that same device. The relays saw requests dropped when the
47
47
  device was new each time.
48
48
 
49
+ ### Enterprise (SaaS) accounts
50
+
51
+ Since 0.2.10 the authorization page also offers Trae's enterprise (SaaS / SSO)
52
+ sign-in; before, the plugin asked the page to hide it (`hide_saas_login`), so
53
+ an enterprise subscription could only sign in as a personal account. A host
54
+ the sign-in names for the account is kept when it is an https host on Trae's
55
+ own domains (`trae.cn`, `trae.ai`, `mchost.guru`): a model host other than the
56
+ consumer site's own (`www`, `api`, `grow…`) takes the account's model
57
+ requests, and a token host the callback names (`authHost`) takes its
58
+ renewals. Any other host is ignored and the consumer hosts are used, as for a
59
+ personal account, which is unchanged. The account's tenant and login-type
60
+ fields are kept with the sign-in; nothing sends them.
61
+
62
+ **Not tried with a real enterprise account.** Which hosts and fields an
63
+ enterprise sign-in sends back is not known yet; if yours doesn't work, open an
64
+ issue with what the sign-in page showed.
65
+
49
66
  ### The international realm's region
50
67
 
51
68
  Trae Global has two deployments. The sign-in's `AIRegion` (or a US auth host
package/index.mjs CHANGED
@@ -79,11 +79,19 @@ const SITES = {
79
79
  // the model host for an account: Trae CN's own hosts serve no models,
80
80
  // so only a host that is one is taken, else the one gateway
81
81
  apiOf: (a) => {
82
- const h = String(a?.api ?? "").replace(/\/+$/, "")
83
- return /mchost\.guru|trae-api-/i.test(h) ? h : SITES["trae-cn"].hosts.api
82
+ const h = traeHost(a?.api)
83
+ return h && SITES["trae-cn"].modelHost(h) ? h : SITES["trae-cn"].hosts.api
84
+ },
85
+ // whether a host named by a sign-in is one of the model hosts: an https
86
+ // host on mchost.guru or named trae-api-…, as the consumer gateway is,
87
+ // or another trae.cn host than the consumer site's own (www, api,
88
+ // grow…), which an enterprise (SaaS) sign-in may name for its tenant
89
+ modelHost: (h) => {
90
+ const t = traeHost(h)
91
+ if (!t) return false
92
+ const n = new URL(t).hostname
93
+ return /(^|\.)mchost\.guru$|^trae-api-/i.test(n) || (/\.trae\.cn$/i.test(n) && !/^(www|api|grow[^.]*)\./i.test(n))
84
94
  },
85
- // whether a host named by a sign-in is one of the model hosts
86
- modelHost: (h) => /mchost\.guru|trae-api-/i.test(String(h ?? "")),
87
95
  // ownPage: url is one of Trae CN's own JSON pages on api.trae.cn
88
96
  // (/trae/api/…: the daily check-in, credits), which the account's fetch
89
97
  // sends as the account rather than as a chat
@@ -166,15 +174,22 @@ const SITES = {
166
174
  // the model host: the account's region (or a US host the sign-in named)
167
175
  // selects the US deployment, else the SG one
168
176
  apiOf: (a) => {
169
- const h = String(a?.api ?? "").replace(/\/+$/, "")
177
+ const h = traeHost(a?.api)
178
+ const n = h ? new URL(h).hostname : ""
170
179
  const region = String(a?.region ?? "").toUpperCase()
171
- if (/coreva/i.test(h) || /api-us(?:[-.]|$)/i.test(h) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
172
- if (/coresg/i.test(h)) return h
180
+ if (/coreva/i.test(n) || /api-us(?:[-.]|$)/i.test(n) || /^US(?:[-_ ]|$)/.test(region)) return SITES["trae-global"].hosts.us
181
+ if (/coresg/i.test(n)) return h
182
+ // another trae.ai host than the site's own (www, api-…, grow…): one an
183
+ // enterprise (SaaS) sign-in named for its tenant
184
+ if (/\.trae\.ai$/i.test(n) && !/^(www\.|api[-.]|grow)/i.test(n)) return h
173
185
  return SITES["trae-global"].hosts.api
174
186
  },
175
187
  // a sign-in names a host of Trae's, which can be an auth host
176
188
  // (api-us-east), not the chat host
177
- modelHost: (h) => /trae\.ai/i.test(String(h ?? "")),
189
+ modelHost: (h) => {
190
+ const t = traeHost(h)
191
+ return !!t && /(^|\.)trae\.ai$/i.test(new URL(t).hostname)
192
+ },
178
193
  // Trae Global serves no own JSON pages through the account's fetch
179
194
  ownPage: () => false,
180
195
  // the account's region, from the sign-in's user info
@@ -213,6 +228,46 @@ const SITES = {
213
228
  },
214
229
  }
215
230
 
231
+ // traeHost is a host a sign-in named, when it is a well-formed https one
232
+ // on Trae's own domains (no port, no credentials), as origin plus any path;
233
+ // "" for anything else, which is then not taken
234
+ const TRAE_DOMAINS = ["trae.cn", "trae.ai", "mchost.guru"]
235
+ function traeHost(h) {
236
+ try {
237
+ const u = new URL(String(h ?? "").trim())
238
+ if (u.protocol !== "https:" || u.username || u.password || u.port || u.search || u.hash) return ""
239
+ const n = u.hostname.toLowerCase()
240
+ if (!TRAE_DOMAINS.some((d) => n === d || n.endsWith("." + d))) return ""
241
+ return (u.origin + u.pathname).replace(/\/+$/, "")
242
+ } catch {
243
+ return ""
244
+ }
245
+ }
246
+
247
+ // authHostOf is the host a sign-in named for its tokens, when it named one
248
+ // on the site's own domain (an enterprise's may differ from the consumer
249
+ // one); "" when it named none, and the site's own is used
250
+ function authHostOf(h, site) {
251
+ const t = traeHost(h)
252
+ if (!t) return ""
253
+ const n = new URL(t).hostname
254
+ return n === site.webHost || n.endsWith("." + site.webHost) ? t : ""
255
+ }
256
+
257
+ // accountOf keeps the account fields of the sign-in's user info that say
258
+ // what kind of account it is (tenant, enterprise, login type), plain values
259
+ // only and never anything that looks like a secret, for later use; nothing
260
+ // sends them
261
+ function accountOf(info) {
262
+ const out = {}
263
+ for (const [k, v] of Object.entries(info ?? {})) {
264
+ if (!/tenant|enterprise|saas|login|org|team|sso|^ent|usertype|accounttype/i.test(k)) continue
265
+ if (/token|secret|key|password|cookie|session|jwt/i.test(k)) continue
266
+ if (v === null || ["string", "number", "boolean"].includes(typeof v)) out[k] = v
267
+ }
268
+ return out
269
+ }
270
+
216
271
  const SIGN_IN_TIMEOUT = 10 * 60 * 1000
217
272
  const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is renewed before a request
218
273
  const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
@@ -301,6 +356,11 @@ function toAuth(s, site) {
301
356
  // the model host the sign-in named, if it named one
302
357
  ...(site.modelHost(s.api) ? { api: s.api } : {}),
303
358
  ...(s.region ? { region: s.region } : {}),
359
+ // the host the sign-in named, its own token host if it named one, and
360
+ // what kind of account it is (an enterprise one's tenant), kept as given
361
+ ...(traeHost(s.host) ? { host: traeHost(s.host) } : {}),
362
+ ...(authHostOf(s.authHost, site) ? { authHost: authHostOf(s.authHost, site) } : {}),
363
+ ...(s.account && Object.keys(s.account).length ? { account: s.account } : {}),
304
364
  }
305
365
  }
306
366
 
@@ -319,6 +379,9 @@ function fromAuth(auth, site) {
319
379
  devicePrivateKey: auth.devicePrivateKey || "",
320
380
  api: auth.api || "",
321
381
  region: site.regionOf(auth),
382
+ host: auth.host || "",
383
+ authHost: authHostOf(auth.authHost, site),
384
+ account: auth.account && typeof auth.account === "object" ? auth.account : {},
322
385
  }
323
386
  }
324
387
 
@@ -405,6 +468,7 @@ async function exchange(refresh, clientId, site, host = site.hosts.auth) {
405
468
  name: String(info.ScreenName ?? info.screenName ?? ""),
406
469
  api: info.Host ?? "",
407
470
  region: site.regionOf(info),
471
+ account: accountOf(info),
408
472
  }
409
473
  }
410
474
  const e = errorOf(text)
@@ -495,12 +559,14 @@ async function boundExchange(a, site, host = site.hosts.auth) {
495
559
  // before 0.2.3 (exchange). A device key made for the account goes with
496
560
  // either, so the next renewal signs with the same one.
497
561
  async function renewal(a, site) {
498
- if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site)
562
+ // the token host the sign-in named, else the site's
563
+ const host = authHostOf(a.authHost, site) || site.hosts.auth
564
+ if (!site.deviceExchange) return exchange(a.refresh, a.clientId, site, host)
499
565
  const keys = a.devicePrivateKey && a.devicePublicKey ? {} : newDeviceKey()
500
566
  try {
501
- return await boundExchange({ ...a, ...keys }, site)
567
+ return await boundExchange({ ...a, ...keys }, site, host)
502
568
  } catch {}
503
- return { ...(await exchange(a.refresh, a.clientId, site)), ...keys }
569
+ return { ...(await exchange(a.refresh, a.clientId, site, host)), ...keys }
504
570
  }
505
571
 
506
572
  // ---- the browser sign-in --------------------------------------------------------
@@ -527,10 +593,15 @@ async function signedIn(q, device, site) {
527
593
  name: String(info.ScreenName ?? info.screenName ?? ""),
528
594
  api: host,
529
595
  region: site.regionOf(info),
596
+ host,
597
+ // a token host the callback named, if it named one
598
+ authHost: authHostOf(q.get("authHost") || q.get("auth_host") || info.AuthHost || info.authHost, site),
599
+ account: accountOf(info),
530
600
  }
601
+ const authHost = s.authHost || site.hosts.auth
531
602
  if (!s.token && s.refresh) {
532
- const x = await exchange(s.refresh, s.clientId, site)
533
- s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region }
603
+ const x = await exchange(s.refresh, s.clientId, site, authHost)
604
+ s = { ...s, ...x, uid: x.uid || s.uid, name: x.name || s.name, api: x.api || s.api, region: x.region || s.region, host: s.host || x.api, account: { ...x.account, ...s.account } }
534
605
  }
535
606
  if (!s.token) throw new Error(`${site.webHost} sent back no token`)
536
607
  if (!s.refresh) throw new Error(`${site.webHost} sent back no refresh token`)
@@ -539,7 +610,7 @@ async function signedIn(q, device, site) {
539
610
  // one at once, so a check-in takes it; when Trae won't, it does as is
540
611
  if (site.deviceExchange) {
541
612
  try {
542
- const x = await boundExchange(s, site)
613
+ const x = await boundExchange(s, site, authHost)
543
614
  s = { ...s, token: x.token, refresh: x.refresh, expires: x.expires || s.expires }
544
615
  } catch {}
545
616
  }
@@ -616,7 +687,9 @@ async function browserSignIn(site) {
616
687
  x_env: "",
617
688
  x_app_version: site.ideVersion,
618
689
  x_app_type: "stable",
619
- hide_saas_login: "true",
690
+ // the enterprise (SaaS) sign-in is offered beside the personal one: an
691
+ // enterprise subscription signs in there (@shad70w0 on X)
692
+ hide_saas_login: "false",
620
693
  })
621
694
  return {
622
695
  url: `${site.hosts.web}/authorization?${q}`,
@@ -2347,4 +2420,4 @@ const makePlugin = (site) => async ({ client }) => {
2347
2420
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2348
2421
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2349
2422
 
2350
- export const _internal = { HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
2423
+ export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",