@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.
- package/README.md +13 -0
- package/README.ru.md +13 -0
- package/README.zh.md +13 -0
- package/lib/accounts-view.js +101 -5
- package/lib/accounts.js +150 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +18 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +944 -828
- package/lib/config-schema.js +46 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/doctor.js +163 -0
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +379 -0
- package/lib/history.js +36 -0
- package/lib/http.js +26 -4
- package/lib/index.js +98 -11
- package/lib/instructions.js +138 -0
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +203 -0
- package/lib/routes/oauth.js +44 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/routes/status.js +99 -5
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +412 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +11 -2
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +8 -1
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +133 -13
- package/lib/vendors/perplexity.js +148 -17
- package/package.json +1 -1
package/lib/stream-rotate.js
CHANGED
|
@@ -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:
|
|
115
|
-
|
|
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, { ...
|
|
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
|
-
|
|
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.
|
|
20
|
+
version: '0.6.77',
|
|
21
21
|
exportedAt: new Date().toISOString(),
|
|
22
22
|
exportedBy,
|
|
23
23
|
slots: Array.isArray(slots) ? slots : [],
|
package/lib/vendor-factory.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|