@goodandready/dsh-subscriptions 0.6.71 → 0.6.82

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 (50) hide show
  1. package/README.md +13 -0
  2. package/README.ru.md +13 -0
  3. package/README.zh.md +13 -0
  4. package/lib/accounts-view.js +101 -5
  5. package/lib/accounts.js +150 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +944 -828
  13. package/lib/config-schema.js +46 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/doctor.js +163 -0
  17. package/lib/error-buffer.js +2 -2
  18. package/lib/forecast.js +56 -0
  19. package/lib/headless-auth.js +379 -0
  20. package/lib/history.js +36 -0
  21. package/lib/http.js +26 -4
  22. package/lib/index.js +98 -11
  23. package/lib/instructions.js +138 -0
  24. package/lib/jwt.js +84 -17
  25. package/lib/loopback.js +3 -2
  26. package/lib/mask.js +16 -0
  27. package/lib/oauth-pending.js +12 -0
  28. package/lib/pricing.js +89 -0
  29. package/lib/refs.js +2 -0
  30. package/lib/reset-clock.js +55 -0
  31. package/lib/rotate.js +18 -4
  32. package/lib/routes/accounts.js +203 -0
  33. package/lib/routes/oauth.js +44 -2
  34. package/lib/routes/proxy.js +14 -0
  35. package/lib/routes/status.js +99 -5
  36. package/lib/scope-inspector.js +51 -0
  37. package/lib/stream-rotate.js +31 -10
  38. package/lib/updater.js +21 -0
  39. package/lib/vault-sync.js +412 -0
  40. package/lib/vault.js +1 -1
  41. package/lib/vendor-factory.js +6 -2
  42. package/lib/vendors/antigravity.js +11 -2
  43. package/lib/vendors/claude.js +56 -8
  44. package/lib/vendors/codex.js +34 -6
  45. package/lib/vendors/copilot-cli.js +8 -1
  46. package/lib/vendors/ernie.js +9 -2
  47. package/lib/vendors/grok.js +66 -6
  48. package/lib/vendors/jetbrains.js +133 -13
  49. package/lib/vendors/perplexity.js +148 -17
  50. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
+ import { inspectAccountCapabilities, normalizeCapabilitiesRequest } from './capabilities.js'
1
2
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
3
  import { recordSessionCacheUsage } from './session-pin.js'
3
- import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
4
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily } from './rotate.js'
4
5
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
5
6
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
6
7
  import { withRunawayGuard } from './runaway-guard.js'
@@ -10,7 +11,7 @@ import { resolveAccurateReset } from './reset-clock.js'
10
11
 
11
12
  export async function* streamWithRotation({
12
13
  accounts,
13
- nowMs,
14
+ nowMs = () => Date.now(),
14
15
  cooldownMs,
15
16
  switchAtRemaining,
16
17
  streamOnce,
@@ -111,8 +112,20 @@ export async function* streamWithRotation({
111
112
  const queueConfig = {
112
113
  signal: options && options.signal,
113
114
  timeoutMs: options && (options.queueTimeoutMs !== undefined ? options.queueTimeoutMs : (options.config && options.config.queueTimeoutMs)),
114
- maxConcurrency: options && (options.accountMaxConcurrency !== undefined ? options.accountMaxConcurrency : (options.config && options.config.accountMaxConcurrency)),
115
- maxQueueSize: options && (options.maxQueueSize !== undefined ? options.maxQueueSize : (options.config && options.config.maxQueueSize)),
115
+ maxConcurrency: account.maxConcurrency !== undefined
116
+ ? account.maxConcurrency
117
+ : (options && options.accountMaxConcurrency !== undefined
118
+ ? options.accountMaxConcurrency
119
+ : (options && options.config && options.config.accountMaxConcurrency !== undefined
120
+ ? options.config.accountMaxConcurrency
121
+ : 1)),
122
+ maxQueueSize: account.maxQueueSize !== undefined
123
+ ? account.maxQueueSize
124
+ : (options && options.maxQueueSize !== undefined
125
+ ? options.maxQueueSize
126
+ : (options && options.config && options.config.maxQueueSize !== undefined
127
+ ? options.config.maxQueueSize
128
+ : 20)),
116
129
  enabled: options && (options.enableQueue !== undefined ? options.enableQueue : (options.config && options.config.enableQueue !== undefined ? options.config.enableQueue : true)),
117
130
  }
118
131
 
@@ -146,15 +159,19 @@ export async function* streamWithRotation({
146
159
  runawayState.reason = reason
147
160
  },
148
161
  }
162
+ const accountCaps = account.capabilities || inspectAccountCapabilities(account.provider || options?.provider, account, account)
163
+ const adaptedOptions = normalizeCapabilitiesRequest(options, accountCaps)
149
164
  const guardedStream = withRunawayGuard(
150
- streamOnce(account, { ...options, _runawayState: runawayState }),
165
+ streamOnce(account, { ...adaptedOptions, _runawayState: runawayState }),
151
166
  runawayOpts
152
167
  )
153
168
  const sentineledStream = withSoftFailureSentinel(guardedStream, {
154
169
  enabled: (options?.enableSoftFailureSentinel !== false) && (options?.config?.enableSoftFailureSentinel !== false),
155
170
  })
156
171
  for await (const chunk of sentineledStream) {
157
- firstChunkDelivered = true
172
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'delta' || chunk.text || chunk.content)) {
173
+ firstChunkDelivered = true
174
+ }
158
175
  const cachedTokens = Number(
159
176
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
160
177
  (chunk && chunk.tokens && (chunk.tokens.cacheRead || chunk.tokens.cached)) ||
@@ -191,7 +208,7 @@ export async function* streamWithRotation({
191
208
  continue
192
209
  }
193
210
 
194
- if (!isSwitchableError(err)) {
211
+ if (!isSwitchableError(err) && !isAuthError(err)) {
195
212
  currentAttempt.status = 'fatal_error'
196
213
  currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
197
214
  syncTrace('fatal_error')
@@ -203,9 +220,10 @@ export async function* streamWithRotation({
203
220
 
204
221
  const accurateUntil = resolveAccurateReset(err, account, options && options.provider, nowMs())
205
222
  const isSoft = Boolean(err && (err.code === 'SOFT_FAILURE' || err.isSoftFailure))
223
+ const isAuth = isAuthError(err)
206
224
 
207
225
  // Move slot to cooldown and quarantine (#172, #538)
208
- const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : cooldownMs
226
+ const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : (isAuth ? 86400000 : cooldownMs)
209
227
  const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
210
228
  account.cooldownUntil = cooled.cooldownUntil
211
229
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
@@ -214,7 +232,7 @@ export async function* streamWithRotation({
214
232
  const code = String(err && err.code || '')
215
233
  const reason = isSoft
216
234
  ? REASON_DEGRADED
217
- : (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
235
+ : (isAuth || status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
218
236
  ? REASON_REVOKED
219
237
  : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
220
238
  ? REASON_HARD_LIMIT
@@ -226,11 +244,14 @@ export async function* streamWithRotation({
226
244
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
227
245
  account.status = err.warmupAccount.status
228
246
  } else {
229
- const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
247
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil || (nowMs() + (isAuth ? 86400000 : 3600000)))
230
248
  account.quarantineUntil = quarantined.quarantineUntil
231
249
  account.quarantineReason = quarantined.quarantineReason
232
250
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
233
251
  }
252
+ if (isAuth || reason === REASON_REVOKED) {
253
+ account.isRevoked = true
254
+ }
234
255
 
235
256
  currentAttempt.quarantineReason = account.quarantineReason || reason
236
257
  currentAttempt.quarantineUntil = account.quarantineUntil
package/lib/updater.js CHANGED
@@ -24,6 +24,27 @@ export function isLoopback(value) {
24
24
  }
25
25
 
26
26
  export function isTrustedUpdateRequest(request, targetCtx) {
27
+ const secFetchSite = String(header(request, 'sec-fetch-site') || '').toLowerCase()
28
+ if (secFetchSite === 'cross-site') return false
29
+
30
+ const origin = header(request, 'origin')
31
+ const host = header(request, 'host')
32
+ if (origin && host) {
33
+ try {
34
+ const u = new URL(origin)
35
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
36
+ if (u.host.toLowerCase() !== host.toLowerCase()) return false
37
+ } else if (u.protocol === 'dsh-app:') {
38
+ const appHost = u.host.toLowerCase()
39
+ if (appHost && appHost !== 'app' && appHost !== 'localhost') return false
40
+ } else {
41
+ return false
42
+ }
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
27
48
  const customHeader = header(request, UPDATE_HEADER)
28
49
  if (customHeader === '1') {
29
50
  const remote = request.socket?.remoteAddress
@@ -0,0 +1,412 @@
1
+ // #562: Automated Encrypted Vault Sync via WebDAV, S3, or Private Gist
2
+ // Supports periodic or on-change backup and synchronization of credentials.
3
+
4
+ import { createHmac, createHash } from 'node:crypto'
5
+ import { exportVault, importVault } from './vault.js'
6
+ import { normalizeSlots } from './accounts.js'
7
+ import { Config, plainConfig } from './config-schema.js'
8
+
9
+ let syncStatus = {
10
+ enabled: false,
11
+ backend: 'webdav',
12
+ lastPushAt: null,
13
+ lastPullAt: null,
14
+ lastPushStatus: 'idle',
15
+ lastPullStatus: 'idle',
16
+ lastExportedAt: null,
17
+ lastError: null,
18
+ }
19
+
20
+ export function getVaultSyncStatus() {
21
+ return { ...syncStatus }
22
+ }
23
+
24
+ export function resetVaultSyncStatus() {
25
+ syncStatus = {
26
+ enabled: false,
27
+ backend: 'webdav',
28
+ lastPushAt: null,
29
+ lastPullAt: null,
30
+ lastPushStatus: 'idle',
31
+ lastPullStatus: 'idle',
32
+ lastExportedAt: null,
33
+ lastError: null,
34
+ }
35
+ }
36
+
37
+ // --- Driver: WebDAV ---
38
+
39
+ export async function pushToWebdav({ url, username, password, encryptedData, fetchFn = fetch }) {
40
+ if (!url) throw new Error('WebDAV URL is required')
41
+ const headers = {
42
+ 'Content-Type': 'text/plain; charset=utf-8',
43
+ }
44
+ if (username || password) {
45
+ headers['Authorization'] = 'Basic ' + Buffer.from(`${username || ''}:${password || ''}`).toString('base64')
46
+ }
47
+ const res = await fetchFn(url, {
48
+ method: 'PUT',
49
+ headers,
50
+ body: encryptedData,
51
+ })
52
+ if (!res.ok && res.status !== 201 && res.status !== 204) {
53
+ throw new Error(`WebDAV push failed: HTTP ${res.status} ${res.statusText}`)
54
+ }
55
+ return { ok: true, status: res.status }
56
+ }
57
+
58
+ export async function pullFromWebdav({ url, username, password, fetchFn = fetch }) {
59
+ if (!url) throw new Error('WebDAV URL is required')
60
+ const headers = {}
61
+ if (username || password) {
62
+ headers['Authorization'] = 'Basic ' + Buffer.from(`${username || ''}:${password || ''}`).toString('base64')
63
+ }
64
+ const res = await fetchFn(url, {
65
+ method: 'GET',
66
+ headers,
67
+ })
68
+ if (!res.ok) {
69
+ throw new Error(`WebDAV pull failed: HTTP ${res.status} ${res.statusText}`)
70
+ }
71
+ return res.text()
72
+ }
73
+
74
+ // --- Driver: Gist (GitHub / Gitea) ---
75
+
76
+ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fetchFn = fetch }) {
77
+ if (!token) throw new Error('Gist access token is required')
78
+ const cleanBase = baseUrl.replace(/\/+$/, '')
79
+ const headers = {
80
+ 'Authorization': `token ${token}`,
81
+ 'Accept': 'application/vnd.github.v3+json, application/json',
82
+ 'Content-Type': 'application/json',
83
+ 'User-Agent': 'dsh-subscriptions-vault-sync',
84
+ }
85
+
86
+ const files = {
87
+ 'dsh-subscriptions-vault.enc': { content: encryptedData },
88
+ }
89
+
90
+ if (gistId) {
91
+ const res = await fetchFn(`${cleanBase}/gists/${gistId}`, {
92
+ method: 'PATCH',
93
+ headers,
94
+ body: JSON.stringify({
95
+ description: 'dsh-subscriptions encrypted vault',
96
+ files,
97
+ }),
98
+ })
99
+ if (!res.ok) throw new Error(`Gist update failed: HTTP ${res.status} ${res.statusText}`)
100
+ const json = await res.json()
101
+ return { ok: true, gistId: json.id || gistId }
102
+ }
103
+
104
+ // Create new secret gist
105
+ const res = await fetchFn(`${cleanBase}/gists`, {
106
+ method: 'POST',
107
+ headers,
108
+ body: JSON.stringify({
109
+ description: 'dsh-subscriptions encrypted vault',
110
+ public: false,
111
+ files,
112
+ }),
113
+ })
114
+ if (!res.ok) throw new Error(`Gist creation failed: HTTP ${res.status} ${res.statusText}`)
115
+ const json = await res.json()
116
+ return { ok: true, gistId: json.id }
117
+ }
118
+
119
+ export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fetchFn = fetch }) {
120
+ if (!gistId) throw new Error('Gist ID is required')
121
+ const cleanBase = baseUrl.replace(/\/+$/, '')
122
+ const headers = {
123
+ 'Accept': 'application/vnd.github.v3+json, application/json',
124
+ 'User-Agent': 'dsh-subscriptions-vault-sync',
125
+ }
126
+ if (token) headers['Authorization'] = `token ${token}`
127
+
128
+ const res = await fetchFn(`${cleanBase}/gists/${gistId}`, {
129
+ method: 'GET',
130
+ headers,
131
+ })
132
+ if (!res.ok) throw new Error(`Gist pull failed: HTTP ${res.status} ${res.statusText}`)
133
+ const json = await res.json()
134
+ const file = json.files && json.files['dsh-subscriptions-vault.enc']
135
+ if (!file || !file.content) {
136
+ throw new Error('dsh-subscriptions-vault.enc not found in Gist')
137
+ }
138
+ return file.content
139
+ }
140
+
141
+ // --- Driver: S3 (SigV4) ---
142
+
143
+ function hmac(key, str) {
144
+ return createHmac('sha256', key).update(str, 'utf8').digest()
145
+ }
146
+
147
+ function sha256Hex(data) {
148
+ return createHash('sha256').update(data, 'utf8').digest('hex')
149
+ }
150
+
151
+ export function buildS3SigV4Headers({ method, url, region = 'us-east-1', accessKey, secretKey, body = '', date = new Date() }) {
152
+ const parsed = new URL(url)
153
+ const host = parsed.host
154
+ const path = parsed.pathname || '/'
155
+
156
+ const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '')
157
+ const dateStamp = amzDate.slice(0, 8)
158
+
159
+ const payloadHash = sha256Hex(body)
160
+ const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
161
+ const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
162
+
163
+ const canonicalReq = [
164
+ method.toUpperCase(),
165
+ path,
166
+ parsed.search.slice(1),
167
+ canonicalHeaders,
168
+ signedHeaders,
169
+ payloadHash,
170
+ ].join('\n')
171
+
172
+ const credentialScope = `${dateStamp}/${region}/s3/aws4_request`
173
+ const stringToSign = [
174
+ 'AWS4-HMAC-SHA256',
175
+ amzDate,
176
+ credentialScope,
177
+ sha256Hex(canonicalReq),
178
+ ].join('\n')
179
+
180
+ const kDate = hmac('AWS4' + secretKey, dateStamp)
181
+ const kRegion = hmac(kDate, region)
182
+ const kService = hmac(kRegion, 's3')
183
+ const kSigning = hmac(kService, 'aws4_request')
184
+ const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')
185
+
186
+ const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
187
+
188
+ return {
189
+ 'host': host,
190
+ 'x-amz-date': amzDate,
191
+ 'x-amz-content-sha256': payloadHash,
192
+ 'Authorization': authHeader,
193
+ }
194
+ }
195
+
196
+ export async function pushToS3({ endpoint, bucket, key = 'dsh-subscriptions-vault.enc', region = 'us-east-1', accessKey, secretKey, encryptedData, fetchFn = fetch }) {
197
+ if (!endpoint || !accessKey || !secretKey) throw new Error('S3 endpoint, access key, and secret key are required')
198
+ let cleanEndpoint = endpoint.replace(/\/+$/, '')
199
+ let targetUrl = bucket ? `${cleanEndpoint}/${bucket}/${key.replace(/^\/+/, '')}` : `${cleanEndpoint}/${key.replace(/^\/+/, '')}`
200
+
201
+ const headers = buildS3SigV4Headers({
202
+ method: 'PUT',
203
+ url: targetUrl,
204
+ region,
205
+ accessKey,
206
+ secretKey,
207
+ body: encryptedData,
208
+ })
209
+ headers['Content-Type'] = 'text/plain; charset=utf-8'
210
+
211
+ const res = await fetchFn(targetUrl, {
212
+ method: 'PUT',
213
+ headers,
214
+ body: encryptedData,
215
+ })
216
+ if (!res.ok && res.status !== 200 && res.status !== 204) {
217
+ throw new Error(`S3 push failed: HTTP ${res.status} ${res.statusText}`)
218
+ }
219
+ return { ok: true, status: res.status }
220
+ }
221
+
222
+ export async function pullFromS3({ endpoint, bucket, key = 'dsh-subscriptions-vault.enc', region = 'us-east-1', accessKey, secretKey, fetchFn = fetch }) {
223
+ if (!endpoint || !accessKey || !secretKey) throw new Error('S3 endpoint, access key, and secret key are required')
224
+ let cleanEndpoint = endpoint.replace(/\/+$/, '')
225
+ let targetUrl = bucket ? `${cleanEndpoint}/${bucket}/${key.replace(/^\/+/, '')}` : `${cleanEndpoint}/${key.replace(/^\/+/, '')}`
226
+
227
+ const headers = buildS3SigV4Headers({
228
+ method: 'GET',
229
+ url: targetUrl,
230
+ region,
231
+ accessKey,
232
+ secretKey,
233
+ body: '',
234
+ })
235
+
236
+ const res = await fetchFn(targetUrl, {
237
+ method: 'GET',
238
+ headers,
239
+ })
240
+ if (!res.ok) {
241
+ throw new Error(`S3 pull failed: HTTP ${res.status} ${res.statusText}`)
242
+ }
243
+ return res.text()
244
+ }
245
+
246
+ // --- Sync Coordination: Push & Pull ---
247
+
248
+ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
249
+ const cfg = live && typeof live === 'function' ? live() : (live || {})
250
+ const passphrase = String(cfg.vaultSyncPassphrase || '').trim()
251
+ if (!passphrase) {
252
+ throw new Error('vaultSyncPassphrase is required to sync vault')
253
+ }
254
+
255
+ syncStatus.enabled = Boolean(cfg.vaultSyncEnabled)
256
+ syncStatus.backend = cfg.vaultSyncBackend || 'webdav'
257
+ syncStatus.lastPushStatus = 'running'
258
+
259
+ try {
260
+ const slots = cfg.slots || []
261
+ const blobs = {}
262
+ for (const slot of normalizeSlots(slots)) {
263
+ try {
264
+ const blob = await store.loadBlob(slot.ref)
265
+ if (blob) blobs[slot.ref] = blob
266
+ } catch { /* ignore */ }
267
+ }
268
+
269
+ const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, passphrase)
270
+ const backend = cfg.vaultSyncBackend || 'webdav'
271
+
272
+ if (backend === 'webdav') {
273
+ await pushToWebdav({
274
+ url: cfg.vaultSyncWebdavUrl,
275
+ username: cfg.vaultSyncWebdavUsername,
276
+ password: cfg.vaultSyncWebdavPassword,
277
+ encryptedData: vault,
278
+ fetchFn,
279
+ })
280
+ } else if (backend === 'gist') {
281
+ const res = await pushToGist({
282
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
283
+ gistId: cfg.vaultSyncGistId,
284
+ token: cfg.vaultSyncGistToken,
285
+ encryptedData: vault,
286
+ fetchFn,
287
+ })
288
+ if (res.gistId && !cfg.vaultSyncGistId) {
289
+ syncStatus.lastGistId = res.gistId
290
+ }
291
+ } else if (backend === 's3') {
292
+ await pushToS3({
293
+ endpoint: cfg.vaultSyncS3Endpoint,
294
+ bucket: cfg.vaultSyncS3Bucket,
295
+ key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
296
+ region: cfg.vaultSyncS3Region || 'us-east-1',
297
+ accessKey: cfg.vaultSyncS3AccessKey,
298
+ secretKey: cfg.vaultSyncS3SecretKey,
299
+ encryptedData: vault,
300
+ fetchFn,
301
+ })
302
+ } else {
303
+ throw new Error(`unsupported vault sync backend: ${backend}`)
304
+ }
305
+
306
+ syncStatus.lastPushAt = new Date().toISOString()
307
+ syncStatus.lastPushStatus = 'ok'
308
+ syncStatus.lastError = null
309
+
310
+ return {
311
+ ok: true,
312
+ backend,
313
+ slotCount,
314
+ blobCount,
315
+ timestamp: syncStatus.lastPushAt,
316
+ }
317
+ } catch (err) {
318
+ syncStatus.lastPushStatus = 'error'
319
+ syncStatus.lastError = String(err && err.message || err)
320
+ throw err
321
+ }
322
+ }
323
+
324
+ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, refreshModels, fetchFn = fetch }) {
325
+ const cfg = live && typeof live === 'function' ? live() : (live || {})
326
+ const passphrase = String(cfg.vaultSyncPassphrase || '').trim()
327
+ if (!passphrase) {
328
+ throw new Error('vaultSyncPassphrase is required to sync vault')
329
+ }
330
+
331
+ syncStatus.enabled = Boolean(cfg.vaultSyncEnabled)
332
+ syncStatus.backend = cfg.vaultSyncBackend || 'webdav'
333
+ syncStatus.lastPullStatus = 'running'
334
+
335
+ try {
336
+ const backend = cfg.vaultSyncBackend || 'webdav'
337
+ let encryptedData = ''
338
+
339
+ if (backend === 'webdav') {
340
+ encryptedData = await pullFromWebdav({
341
+ url: cfg.vaultSyncWebdavUrl,
342
+ username: cfg.vaultSyncWebdavUsername,
343
+ password: cfg.vaultSyncWebdavPassword,
344
+ fetchFn,
345
+ })
346
+ } else if (backend === 'gist') {
347
+ encryptedData = await pullFromGist({
348
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
349
+ gistId: cfg.vaultSyncGistId,
350
+ token: cfg.vaultSyncGistToken,
351
+ fetchFn,
352
+ })
353
+ } else if (backend === 's3') {
354
+ encryptedData = await pullFromS3({
355
+ endpoint: cfg.vaultSyncS3Endpoint,
356
+ bucket: cfg.vaultSyncS3Bucket,
357
+ key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
358
+ region: cfg.vaultSyncS3Region || 'us-east-1',
359
+ accessKey: cfg.vaultSyncS3AccessKey,
360
+ secretKey: cfg.vaultSyncS3SecretKey,
361
+ fetchFn,
362
+ })
363
+ } else {
364
+ throw new Error(`unsupported vault sync backend: ${backend}`)
365
+ }
366
+
367
+ const result = importVault(encryptedData, passphrase)
368
+
369
+ // Merge slots into live configuration
370
+ if (result.slots.length && settingsApi) {
371
+ const curSlots = Array.isArray(cfg.slots) ? cfg.slots.slice() : []
372
+ for (const s of result.slots) {
373
+ const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
374
+ if (idx >= 0) {
375
+ curSlots[idx] = { ...curSlots[idx], ...s }
376
+ } else {
377
+ curSlots.push(s)
378
+ }
379
+ }
380
+ const parsed = plainConfig(Config({ ...cfg, slots: curSlots }))
381
+ await settingsApi.replace(parsed)
382
+ }
383
+
384
+ // Save blobs into store
385
+ let blobCount = 0
386
+ for (const [ref, blob] of Object.entries(result.blobs)) {
387
+ await store.saveBlob(ref, blob)
388
+ blobCount++
389
+ }
390
+
391
+ if (typeof syncAdapter === 'function') await syncAdapter()
392
+ if (typeof refreshModels === 'function') refreshModels().catch(() => {})
393
+
394
+ syncStatus.lastPullAt = new Date().toISOString()
395
+ syncStatus.lastExportedAt = result.exportedAt
396
+ syncStatus.lastPullStatus = 'ok'
397
+ syncStatus.lastError = null
398
+
399
+ return {
400
+ ok: true,
401
+ backend,
402
+ slotCount: result.slots.length,
403
+ blobCount,
404
+ exportedAt: result.exportedAt,
405
+ timestamp: syncStatus.lastPullAt,
406
+ }
407
+ } catch (err) {
408
+ syncStatus.lastPullStatus = 'error'
409
+ syncStatus.lastError = String(err && err.message || err)
410
+ throw err
411
+ }
412
+ }
package/lib/vault.js CHANGED
@@ -17,7 +17,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
17
17
 
18
18
  const payload = {
19
19
  format: VAULT_FORMAT,
20
- version: '0.6.18',
20
+ version: '0.6.77',
21
21
  exportedAt: new Date().toISOString(),
22
22
  exportedBy,
23
23
  slots: Array.isArray(slots) ? slots : [],
@@ -111,14 +111,14 @@ export function createVendorFromProfile(profile) {
111
111
  return null
112
112
  },
113
113
  check: undefined,
114
- async* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
114
+ async* streamOnce({ blob, options, fetchImpl, headers, signal }) {
115
115
  const body = codexResponsesBody(options, "")
116
116
  if (!body.instructions) delete body.instructions
117
117
  const res = await fetchWithTimeout(fetchImpl || fetch, `${base}/responses`, {
118
118
  method: "POST",
119
119
  headers: {
120
120
  ...headers,
121
- ...identityHeaders(blob, config),
121
+ ...identityHeaders(blob),
122
122
  "Content-Type": "application/json",
123
123
  Accept: "text/event-stream",
124
124
  },
@@ -139,12 +139,16 @@ export function validateProfile(raw) {
139
139
  }
140
140
  if (!/^[a-z][a-z0-9_]*$/.test(p.id)) throw new Error(`customVendors: id "${p.id}" must match [a-z][a-z0-9_]*`)
141
141
  if (p.tokenStyle && !["form", "json"].includes(p.tokenStyle)) throw new Error(`customVendors[${p.id}]: tokenStyle must be form|json`)
142
+ const localHostnames = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
142
143
  for (const key of ["authUrl", "tokenUrl", "baseUrl"]) {
143
144
  try {
144
145
  const u = new URL(p[key])
145
146
  if (u.protocol !== "https:" && u.protocol !== "http:") {
146
147
  throw new Error("protocol must be http: or https:")
147
148
  }
149
+ if (u.protocol === "http:" && !localHostnames.has(u.hostname)) {
150
+ throw new Error("http is allowed only for localhost")
151
+ }
148
152
  } catch (e) {
149
153
  throw new Error(`customVendors[${p.id}]: field ${key} must be a valid http(s) URL (${e.message || e})`)
150
154
  }
@@ -3,7 +3,7 @@ import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { googleContents } from '../messages.js'
4
4
  import { formTokenRequest, googleStream, throwHttpError, tokenBlobFromOAuth } from '../wire.js'
5
5
  import { fetchWithTimeout } from '../http.js'
6
- import { emailFromToken } from '../jwt.js'
6
+ import { emailFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
7
7
  import {
8
8
  CODE_ASSIST_STREAM,
9
9
  antigravityMetadata,
@@ -125,7 +125,16 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
125
125
  clientId: cfg.clientId,
126
126
  ...(cfg.clientSecret ? { clientSecret: cfg.clientSecret } : {}),
127
127
  })
128
- const labeled = { ...blob, email: blob.email || emailFromToken(blob.accessToken), label: blob.label || 'Antigravity' }
128
+ const email = blob.email || emailFromToken(blob.idToken || blob.accessToken)
129
+ const avatarUrl = blob.avatarUrl || avatarFromToken(blob.idToken || blob.accessToken)
130
+ const scopes = blob.scopes?.length ? blob.scopes : (json?.scope ? String(json.scope).split(/[\s,]+/).filter(Boolean) : scopesFromToken(blob.idToken || blob.accessToken))
131
+ const labeled = {
132
+ ...blob,
133
+ email,
134
+ avatarUrl,
135
+ scopes,
136
+ label: blob.label || email || "Antigravity",
137
+ }
129
138
  return withProject(labeled, fetchImpl)
130
139
  }
131
140