@magpie-community/opencode-workbuddy-auth 0.1.6 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/index.mjs +81 -13
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -33,6 +33,16 @@ marker, `{"type": "oauth", "source": "desktop", "accountId", "uid"}`. OpenCode k
33
33
  refreshed a minute before it ends, and the new one is saved. If the refresh
34
34
  fails, the old token is used while it lasts.
35
35
 
36
+ In magpie (0.1.684 or later) the plugin also gives `auth.refresh` with
37
+ `refreshLead` of 5 minutes: magpie renews a browser sign-in (an hour's
38
+ token) 5 minutes before it ends, once for the account, before its
39
+ requests, models and usage ask, and saves what it gives. It goes through
40
+ the same one-refresh-at-a-time as a request's refresh, so a refresh token
41
+ is never spent twice. A failed renewal, a refresh token past its end
42
+ included, doesn't mark the account, as the built-in never did; magpie
43
+ tries again later, and the old token is used meanwhile.
44
+ OpenCode doesn't call `auth.refresh`; the check before each request stays.
45
+
36
46
  Desktop's sign-in is refreshed only when the app hasn't refreshed it
37
47
  itself, and the new token is kept in memory, never written back, as magpie's
38
48
  built-in WorkBuddy does.
package/index.mjs CHANGED
@@ -17,7 +17,9 @@ const APP_VERSION = "2.0.0" // the version WorkBuddy's sign-in page is opened wi
17
17
  const UA_VERSION = "5.5.6" // WorkBuddy/<this> is the User-Agent the API takes
18
18
  const POLL_MS = 1000
19
19
  const SIGN_IN_MS = 5 * 60 * 1000
20
- const EARLY_MS = 60 * 1000 // a token this close to its end is refreshed
20
+ const EARLY_MS = 60 * 1000 // a token this close to its end is refreshed before a request
21
+ const LEAD_MS = 5 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
22
+ const KEEP_RENEWED_MS = 60 * 1000 // how long a refresh's result answers for the old refresh token
21
23
 
22
24
  // [id, name, context, efforts]
23
25
  const CN_MODELS = [
@@ -146,27 +148,66 @@ function stale(a) {
146
148
  return !!a.expires && Date.now() >= a.expires - EARLY_MS
147
149
  }
148
150
 
151
+ // renewing is each refresh under way, by site and the refresh token it
152
+ // spends; renewed is what each gave, kept a minute so one that read the
153
+ // old refresh token after it was spent (magpie's auth.refresh, a request)
154
+ // takes that rather than spending it again.
155
+ const renewing = new Map()
156
+ const renewed = new Map()
157
+
158
+ function refreshable(a) {
159
+ return !!a.refresh && !(a.refreshExpiresAt && Date.now() >= a.refreshExpiresAt)
160
+ }
161
+
162
+ // renew spends a's refresh token, once: those who come while it runs, or
163
+ // within KEEP_RENEWED_MS of it, get what it gave. save keeps the result.
164
+ function renew(site, a, save) {
165
+ const key = site.id + "\n" + a.refresh
166
+ const done = renewed.get(key)
167
+ if (done && Date.now() - done.at < KEEP_RENEWED_MS) return Promise.resolve(merged(a, done.next))
168
+ let r = renewing.get(key)
169
+ if (!r) {
170
+ r = (async () => {
171
+ const got = await call(site, "POST", "/v2/plugin/auth/token/refresh", {
172
+ "X-Refresh-Token": a.refresh,
173
+ "X-Auth-Refresh-Source": "plugin",
174
+ "X-Domain": domainOf(site, a),
175
+ }, {})
176
+ if (!got?.accessToken) throw Object.assign(new Error("WorkBuddy gave no refreshed token"), { bare: true })
177
+ const next = merge(a, got)
178
+ renewed.set(key, { next, at: Date.now() })
179
+ await save?.(next)
180
+ return next
181
+ })().finally(() => renewing.delete(key))
182
+ renewing.set(key, r)
183
+ }
184
+ return r.then((next) => merged(a, next))
185
+ }
186
+
187
+ // merged is a with the tokens another's refresh gave over it.
188
+ function merged(a, next) {
189
+ const out = { ...a }
190
+ for (const k of TOKEN_FIELDS) if (k in next) out[k] = next[k]
191
+ return out
192
+ }
193
+ const TOKEN_FIELDS = ["access", "refresh", "expires", "refreshExpiresAt", "domain", "tokenType"]
194
+
149
195
  // fresh is the sign-in with an access token that isn't about to end:
150
196
  // refreshed (and saved) when it is. A refresh that fails, or a refresh
151
197
  // token that has ended, leaves the access token there is. Its failures
152
198
  // are worded as the built-in's (wbFresh), "WorkBuddy" on both sites.
153
199
  async function fresh(site, client, a) {
154
200
  if (!stale(a)) return a
155
- const refreshable = a.refresh && !(a.refreshExpiresAt && Date.now() >= a.refreshExpiresAt)
156
- if (!refreshable) {
201
+ if (!refreshable(a)) {
157
202
  if (a.access) return a
158
203
  throw new Error("this WorkBuddy account is signed out; sign in again")
159
204
  }
160
205
  try {
161
- const got = await call(site, "POST", "/v2/plugin/auth/token/refresh", {
162
- "X-Refresh-Token": a.refresh,
163
- "X-Auth-Refresh-Source": "plugin",
164
- "X-Domain": domainOf(site, a),
165
- }, {})
166
- if (!got?.accessToken) throw Object.assign(new Error("WorkBuddy gave no refreshed token"), { bare: true })
167
- const next = merge(a, got)
168
- await client?.auth?.set?.({ path: { id: site.id }, body: next })
169
- return next
206
+ return await renew(site, a, async (next) => {
207
+ try {
208
+ await client?.auth?.set?.({ path: { id: site.id }, body: next })
209
+ } catch {}
210
+ })
170
211
  } catch (e) {
171
212
  if (a.access) return a
172
213
  if (e?.bare) throw e
@@ -174,6 +215,26 @@ async function fresh(site, client, a) {
174
215
  }
175
216
  }
176
217
 
218
+ // refreshed is what magpie's auth.refresh gives for a browser sign-in:
219
+ // the fields a refresh changed, nothing when there's nothing to renew.
220
+ // magpie saves them itself.
221
+ async function refreshed(site, auth) {
222
+ if (auth?.type !== "oauth" || auth.source === "desktop" || !auth.access || !auth.refresh) return undefined
223
+ // a refresh token that has ended is said plainly, never as signIn
224
+ // "expired": the built-in never marked a WorkBuddy account
225
+ if (!refreshable(auth)) throw new Error("this WorkBuddy account is signed out; sign in again")
226
+ let next
227
+ try {
228
+ next = await renew(site, auth)
229
+ } catch (e) {
230
+ if (e?.bare) throw e
231
+ throw new Error(`WorkBuddy token refresh: ${e?.said ?? e?.message ?? e}`)
232
+ }
233
+ const out = {}
234
+ for (const k of TOKEN_FIELDS) if (k in next && next[k] !== auth[k]) out[k] = next[k]
235
+ return Object.keys(out).length ? out : undefined
236
+ }
237
+
177
238
  // sign sets on headers what WorkBuddy's desktop app sends with a chat.
178
239
  function sign(site, a, headers) {
179
240
  headers.set("Authorization", "Bearer " + a.access)
@@ -571,6 +632,13 @@ function makePlugin(site) {
571
632
  },
572
633
  auth: {
573
634
  provider: site.id,
635
+ // magpie renews a browser sign-in LEAD_MS before its end (an hour's
636
+ // token), once for the account, before its requests, models and
637
+ // usage ask; the check before each request stays for OpenCode,
638
+ // which doesn't call this. Desktop's sign-in has no expiry stored
639
+ // and is renewed as before.
640
+ refreshLead: LEAD_MS,
641
+ refresh: (auth) => refreshed(site, auth),
574
642
  async loader(getAuth) {
575
643
  const auth = await getAuth()
576
644
  if (auth?.type !== "oauth") return {}
@@ -676,4 +744,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
676
744
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
677
745
 
678
746
  // for tests
679
- export const _internal = { withSystem, usageOf, desktopHeld, explained, REFUSED_HINT, freeCredits, creditsOf, fresh, SITES }
747
+ export const _internal = { withSystem, usageOf, desktopHeld, explained, REFUSED_HINT, freeCredits, creditsOf, fresh, SITES, renewing, renewed }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": "./index.mjs",
8
8
  "files": ["index.mjs", "README.md"],
9
9
  "keywords": ["opencode", "opencode-plugin", "workbuddy", "codebuddy"],
10
- "license": "MIT"
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/workbuddy" }
11
12
  }