@magpie-community/opencode-qoder-auth 0.2.14 → 0.2.15

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 (2) hide show
  1. package/index.mjs +118 -2
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -311,6 +311,64 @@ async function deviceSignIn(site) {
311
311
  }
312
312
  }
313
313
 
314
+ // exchangeExpiry is when a job token from /api/v1/jobToken/exchange runs
315
+ // out, as Qoder's own client reads it: expire_time or expires_at (a date)
316
+ // first, then expires_in (milliseconds), then a day.
317
+ function exchangeExpiry(jt) {
318
+ for (const k of ["expire_time", "expires_at"]) {
319
+ const at = Date.parse(String(jt?.[k] ?? "").trim())
320
+ if (!isNaN(at)) return at
321
+ }
322
+ if (jt?.expires_in > 0) return Date.now() + jt.expires_in
323
+ return Date.now() + DAY
324
+ }
325
+
326
+ // exchangeToken is the job token from an exchange response, as Qoder's
327
+ // client reads it: token, device_token or access_token.
328
+ function exchangeToken(jt) {
329
+ return String(jt?.token ?? jt?.device_token ?? jt?.access_token ?? "").trim()
330
+ }
331
+
332
+ // patSignIn exchanges a Personal Access Token for a job token, bypassing
333
+ // the OAuth device flow. PATs are long-lived (up to 1 year): the PAT is
334
+ // kept on the credential, and when the job token's refresh lapses the PAT
335
+ // is exchanged again rather than asking the user to sign in once more.
336
+ async function patSignIn(site, pat) {
337
+ const machineId = randomUUID()
338
+ const r = await openapi(site, "/api/v1/jobToken/exchange", { method: "POST", body: { personal_token: pat } })
339
+ if (r.status === 400 || r.status === 401 || r.status === 403)
340
+ throw Object.assign(new Error(`${site.name} refused the PAT — check it is still valid`), { status: r.status })
341
+ if (r.status !== 200) throw new Error(`${site.name} PAT exchange: status ${r.status}`)
342
+ const token = exchangeToken(r.json)
343
+ if (!token) throw new Error(`${site.name} PAT exchange: empty token in response`)
344
+ const refresh = String(r.json?.refresh_token ?? r.json?.refreshToken ?? "").trim()
345
+ const chat = { access: token, refresh, expires: exchangeExpiry(r.json) }
346
+ let email = ""
347
+ let name = ""
348
+ let uid = ""
349
+ try {
350
+ const ui = await openapi(site, "/api/v1/userinfo", { token })
351
+ if (ui.status >= 200 && ui.status < 300) {
352
+ ;[email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
353
+ uid = String(ui.json?.id ?? ui.json?.user_id ?? ui.json?.uid ?? "")
354
+ }
355
+ } catch {}
356
+ if (!uid) throw new Error(`${site.name} PAT sign-in: could not determine user id`)
357
+ return {
358
+ type: "success",
359
+ ...chat,
360
+ accountId: email || uid,
361
+ uid,
362
+ email,
363
+ name: name || "",
364
+ machineId,
365
+ deviceToken: "",
366
+ deviceRefresh: "",
367
+ patAuth: true,
368
+ pat,
369
+ }
370
+ }
371
+
314
372
  // SignInGone is a sign-in that can't sign a request: a 401. Only Qoder
315
373
  // refusing the refresh token marks the account lapsed (expired), as the
316
374
  // built-in's qoderRefreshFailed did; no refresh token, or no account,
@@ -1372,7 +1430,8 @@ const makePlugin = (site) => async ({ client }) => {
1372
1430
  return got
1373
1431
  }
1374
1432
  // renew spends a's chat refresh token, under the lock: the fields it
1375
- // changes, recorded in spent.
1433
+ // changes, recorded in spent. A PAT account whose job refresh has lapsed
1434
+ // exchanges the PAT again: the PAT lasts a year, the refresh two days.
1376
1435
  const renew = async (a) => {
1377
1436
  const s = siteOf(site, a)
1378
1437
  if (a.deviceChat) {
@@ -1381,7 +1440,22 @@ const makePlugin = (site) => async ({ client }) => {
1381
1440
  const dt = await refreshDevice(s, a.refresh, true)
1382
1441
  return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1383
1442
  }
1384
- const jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1443
+ let jt
1444
+ try {
1445
+ jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1446
+ } catch (e) {
1447
+ if (!(e instanceof SignInGone) || !a.patAuth || !a.pat) throw e
1448
+ let ex
1449
+ try {
1450
+ ex = await patSignIn(s, a.pat)
1451
+ } catch (p) {
1452
+ // a PAT lapses too — out of date, or revoked: Qoder's refusal ends
1453
+ // the sign-in as a lapsed device one does, not as a server fault
1454
+ if (!p?.status) throw p
1455
+ throw new SignInGone(`${site.name} refused the PAT — sign in again with a new one`, true)
1456
+ }
1457
+ return spend(a.refresh, { access: ex.access, refresh: ex.refresh, expires: ex.expires })
1458
+ }
1385
1459
  return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1386
1460
  }
1387
1461
  // each account's listing, the model configs a request carries
@@ -1450,6 +1524,14 @@ const makePlugin = (site) => async ({ client }) => {
1450
1524
  try {
1451
1525
  const cred = await fresh(getAuth)
1452
1526
  if (renewed(cred)) signIn = "renewed"
1527
+ // A PAT account has no device token: the usage endpoint takes the job
1528
+ // token the exchange gave — a live PAT account answered 200 with a
1529
+ // qoder envelope. Its errors surface as the device path's do; there is
1530
+ // no second token here to rotate and retry with.
1531
+ if (cred.patAuth && !cred.deviceToken) {
1532
+ const env = await fetchUsage(siteOf(site, cred), cred.access)
1533
+ return { ...parseUsage(env), signIn }
1534
+ }
1453
1535
  let env
1454
1536
  try {
1455
1537
  env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
@@ -1583,6 +1665,40 @@ const makePlugin = (site) => async ({ client }) => {
1583
1665
  },
1584
1666
  methods: [
1585
1667
  { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1668
+ // PAT sign-in: exchange a Personal Access Token for a job token,
1669
+ // bypassing the OAuth device flow. PATs last up to 1 year.
1670
+ // Not offered on Qoder CN: its CLI uses device chat directly and
1671
+ // never makes a job token, so a PAT exchange may not produce one
1672
+ // the chat endpoint accepts.
1673
+ ...(site.id !== "qoder-cn"
1674
+ ? [
1675
+ {
1676
+ type: "oauth",
1677
+ label: `Sign in with ${site.name} PAT`,
1678
+ prompts: [
1679
+ {
1680
+ type: "password",
1681
+ key: "pat",
1682
+ message: "Your Qoder Personal Access Token (from qoder.com/account/integrations)",
1683
+ placeholder: "pt-...",
1684
+ validate: (v) => (String(v ?? "").trim().startsWith("pt-") ? undefined : "PAT must start with pt-"),
1685
+ },
1686
+ ],
1687
+ authorize: (inputs) => {
1688
+ const pat = String(inputs?.pat ?? "").trim()
1689
+ if (!pat) throw new Error("enter your Personal Access Token")
1690
+ return {
1691
+ url: `${site.web}/account/integrations`,
1692
+ instructions: `Create a PAT at ${site.web}/account/integrations if you don't have one`,
1693
+ method: "auto",
1694
+ async callback() {
1695
+ return patSignIn(site, pat)
1696
+ },
1697
+ }
1698
+ },
1699
+ },
1700
+ ]
1701
+ : []),
1586
1702
  // an enterprise VPC instance (Qoder CN), a method of its own so
1587
1703
  // that the sign-in above asks nothing new
1588
1704
  ...(site.vpcDomain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",