@bivy/bivy 0.6.0-staging.88 → 0.6.0-staging.89
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.
|
@@ -34,6 +34,37 @@ function isStoredCredential(value) {
|
|
|
34
34
|
function providerId(id) {
|
|
35
35
|
return String(id ?? "").trim().toLowerCase();
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Should an `incoming` credential replace the `local` one during a non-destructive
|
|
39
|
+
* `importAll` merge? Pure and exported so the convergence rule is unit-testable
|
|
40
|
+
* without a vault. Rules:
|
|
41
|
+
* - No local entry → take the incoming one.
|
|
42
|
+
* - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
|
|
43
|
+
* type switch, keeps the existing "incoming wins on a real content change").
|
|
44
|
+
* - A snapshot that omits the refresh token must never clobber a usable one —
|
|
45
|
+
* rotated refresh tokens are single-use, so an incoming with a blank refresh is
|
|
46
|
+
* strictly worse than a local one that still has it.
|
|
47
|
+
* - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
|
|
48
|
+
* both carry it; otherwise fall back to the access-token `expires`. In both
|
|
49
|
+
* cases a tie KEEPS the local credential (strictly-greater wins), so an equal
|
|
50
|
+
* stamp can't needlessly churn/rotate the vault, and clock skew can't let an
|
|
51
|
+
* equal-`expires` stale token win.
|
|
52
|
+
*/
|
|
53
|
+
export function preferIncomingCredential(local, incoming) {
|
|
54
|
+
if (!local)
|
|
55
|
+
return true;
|
|
56
|
+
if (local.type !== "oauth" || incoming.type !== "oauth")
|
|
57
|
+
return true;
|
|
58
|
+
const localRefresh = String(local.refresh ?? "").trim();
|
|
59
|
+
const incomingRefresh = String(incoming.refresh ?? "").trim();
|
|
60
|
+
if (!incomingRefresh && localRefresh)
|
|
61
|
+
return false;
|
|
62
|
+
const lt = Number(local.refreshedAt);
|
|
63
|
+
const it = Number(incoming.refreshedAt);
|
|
64
|
+
if (Number.isFinite(lt) && Number.isFinite(it))
|
|
65
|
+
return it > lt;
|
|
66
|
+
return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
|
|
67
|
+
}
|
|
37
68
|
/**
|
|
38
69
|
* Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
|
|
39
70
|
*
|
|
@@ -202,12 +233,10 @@ export class BivyCredentialStore {
|
|
|
202
233
|
if (!id || !isStoredCredential(incoming))
|
|
203
234
|
continue;
|
|
204
235
|
const local = vault[id];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
236
|
+
// Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
|
|
237
|
+
// or refresh-less snapshot must not overwrite a fresher local login.
|
|
238
|
+
if (!preferIncomingCredential(local, incoming))
|
|
239
|
+
continue;
|
|
211
240
|
if (!(id in vault))
|
|
212
241
|
imported += 1;
|
|
213
242
|
// Only mark dirty on a real content change, so a snapshot that merely
|
|
@@ -63,14 +63,15 @@ function tokensFrom(provider, payload, prev) {
|
|
|
63
63
|
const rotated = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
|
|
64
64
|
const refresh = rotated || prev?.refresh || "";
|
|
65
65
|
const expiresIn = Number(payload.expires_in) || 3600;
|
|
66
|
-
const
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const expires = now + expiresIn * 1000 - provider.refreshSkewMs;
|
|
67
68
|
let accountId = prev?.accountId;
|
|
68
69
|
if (provider.accountIdClaim) {
|
|
69
70
|
accountId = jwtClaim(access, provider.accountIdClaim.path, provider.accountIdClaim.field) ?? accountId;
|
|
70
71
|
if (!accountId)
|
|
71
72
|
throw new Error(`Could not extract account id for "${provider.id}" from the OAuth token`);
|
|
72
73
|
}
|
|
73
|
-
return { access, refresh, expires, ...(accountId ? { accountId } : {}) };
|
|
74
|
+
return { access, refresh, expires, refreshedAt: now, ...(accountId ? { accountId } : {}) };
|
|
74
75
|
}
|
|
75
76
|
// --- Authorization-code flow (browser + callback server + manual paste) ------
|
|
76
77
|
function buildAuthorizeUrl(provider, opts) {
|
|
@@ -282,7 +283,7 @@ export async function loginModelOAuth(credsDir, providerId, interaction) {
|
|
|
282
283
|
if (!provider)
|
|
283
284
|
throw new Error(`Provider "${providerId}" does not support subscription login`);
|
|
284
285
|
const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
|
|
285
|
-
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
|
+
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
287
|
await createCredentialVault(credsDir).modify(providerId, async () => credential);
|
|
287
288
|
}
|
|
288
289
|
/** Exchange the refresh token for a fresh credential (network call; throws on failure). */
|
|
@@ -318,7 +319,7 @@ export async function refreshModelOAuth(credsDir, providerId) {
|
|
|
318
319
|
if (Number(current.expires) > Date.now())
|
|
319
320
|
return current;
|
|
320
321
|
const fresh = await refreshTokens(provider, current);
|
|
321
|
-
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
|
+
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, refreshedAt: fresh.refreshedAt, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
323
|
});
|
|
323
324
|
return result?.type === "oauth" ? result.access : undefined;
|
|
324
325
|
}
|
package/package.json
CHANGED