@goodandready/dsh-subscriptions 0.6.43 → 0.6.45
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/lib/accounts.js +77 -5
- package/lib/adapter-manager.js +4 -0
- package/lib/crypto.js +1 -1
- package/lib/http.js +40 -9
- package/lib/index.js +17 -8
- package/lib/rotate.js +10 -2
- package/lib/routes/accounts.js +1 -1
- package/lib/routes/status.js +45 -5
- package/lib/stream-rotate.js +1 -0
- package/lib/subscriptions.js +22 -3
- package/lib/vault.js +1 -1
- package/package.json +1 -1
package/lib/accounts.js
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
function resolveQuarantineFilePath() {
|
|
6
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
7
|
+
return join(base, 'subscriptions', 'quarantines.json')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function loadQuarantinesFromFile(map) {
|
|
11
|
+
try {
|
|
12
|
+
const p = resolveQuarantineFilePath()
|
|
13
|
+
const content = readFileSync(p, 'utf8')
|
|
14
|
+
const json = JSON.parse(content)
|
|
15
|
+
const now = Date.now()
|
|
16
|
+
if (json && typeof json === 'object') {
|
|
17
|
+
for (const [k, v] of Object.entries(json)) {
|
|
18
|
+
if (v && Number(v.until) > now) {
|
|
19
|
+
map.set(k, v)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch { /* best-effort */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveQuarantinesToFile(map) {
|
|
27
|
+
try {
|
|
28
|
+
const p = resolveQuarantineFilePath()
|
|
29
|
+
mkdirSync(join(p, '..'), { recursive: true })
|
|
30
|
+
const obj = {}
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
for (const [k, v] of map.entries()) {
|
|
33
|
+
if (v && Number(v.until) > now) {
|
|
34
|
+
obj[k] = v
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(p, JSON.stringify(obj), 'utf8')
|
|
38
|
+
} catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sharedQuarantines = new Map()
|
|
42
|
+
loadQuarantinesFromFile(sharedQuarantines)
|
|
43
|
+
|
|
1
44
|
import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
|
|
2
45
|
import { fetchWithTimeout, isTimeoutError } from "./http.js"
|
|
3
46
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
@@ -62,8 +105,10 @@ export function vendorConfig(provider, cfg) {
|
|
|
62
105
|
export function createAccountStore({ credentials, getConfig, fetchImpl, fetchForRef, onLimitNotice }) {
|
|
63
106
|
const cooldowns = new Map()
|
|
64
107
|
const quotas = new Map()
|
|
65
|
-
const quarantines =
|
|
108
|
+
const quarantines = sharedQuarantines
|
|
66
109
|
const writeQueues = new Map()
|
|
110
|
+
const credentialVersions = new Map()
|
|
111
|
+
const clearedRefs = new Set()
|
|
67
112
|
const refreshLocks = new Map()
|
|
68
113
|
const refreshFailures = new Map()
|
|
69
114
|
const usage = new Map()
|
|
@@ -98,11 +143,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
98
143
|
|
|
99
144
|
async function saveBlob(ref, blob) {
|
|
100
145
|
if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
|
|
146
|
+
clearedRefs.delete(ref)
|
|
147
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
148
|
+
credentialVersions.set(ref, version)
|
|
149
|
+
|
|
101
150
|
const prev = writeQueues.get(ref) || Promise.resolve()
|
|
102
151
|
const next = prev.catch(() => {}).then(async () => {
|
|
152
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
103
155
|
try {
|
|
104
156
|
await credentials.set(credentialRef(ref), serializeBlob(blob))
|
|
157
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
158
|
+
try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
|
|
159
|
+
}
|
|
105
160
|
} catch (err) {
|
|
161
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
106
164
|
const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
|
|
107
165
|
wrapErr.code = "CREDENTIAL_SAVE_FAILED"
|
|
108
166
|
wrapErr.cause = err
|
|
@@ -115,6 +173,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
115
173
|
|
|
116
174
|
async function clearRef(ref) {
|
|
117
175
|
if (!ref || typeof ref !== "string") return
|
|
176
|
+
clearedRefs.add(ref)
|
|
177
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
178
|
+
credentialVersions.set(ref, version)
|
|
118
179
|
try {
|
|
119
180
|
await credentials.unset(credentialRef(ref))
|
|
120
181
|
} catch { /* best-effort unset */ }
|
|
@@ -165,6 +226,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
165
226
|
quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
|
|
166
227
|
cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
|
|
167
228
|
quota: quotas.get(ref) || null,
|
|
229
|
+
pacePerHour: quotas.get(ref)?.pacePerHour || 0,
|
|
168
230
|
usage: windows.get(ref) || base.usage || null,
|
|
169
231
|
health: health.get(ref) || null,
|
|
170
232
|
healthScore: computeHealthScore(health.get(ref) || null),
|
|
@@ -200,6 +262,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
200
262
|
quarantineUntil: inQ ? q.until : 0,
|
|
201
263
|
quarantineReason: inQ ? q.reason : null,
|
|
202
264
|
label: slot.label || info.label,
|
|
265
|
+
pacePerHour: info.pacePerHour || 0,
|
|
203
266
|
})
|
|
204
267
|
}
|
|
205
268
|
return out
|
|
@@ -320,11 +383,18 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
320
383
|
windows.set(slot.ref, list)
|
|
321
384
|
// persist last known windows so the card survives restarts
|
|
322
385
|
try {
|
|
323
|
-
|
|
386
|
+
if (clearedRefs.has(slot.ref)) return
|
|
387
|
+
let currentBlob = null
|
|
388
|
+
try {
|
|
389
|
+
const currentRaw = await resolveRaw(slot.ref)
|
|
390
|
+
if (currentRaw) currentBlob = parseBlob(currentRaw)
|
|
391
|
+
} catch { /* best-effort */ }
|
|
392
|
+
if (!currentBlob || clearedRefs.has(slot.ref)) return
|
|
393
|
+
const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
|
|
324
394
|
if (prev !== JSON.stringify(list)) {
|
|
325
|
-
await saveBlob(slot.ref, { ...
|
|
326
|
-
} else if (!
|
|
327
|
-
await saveBlob(slot.ref, { ...
|
|
395
|
+
await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
|
|
396
|
+
} else if (!currentBlob.usageAt) {
|
|
397
|
+
await saveBlob(slot.ref, { ...currentBlob, usageAt: Date.now() })
|
|
328
398
|
}
|
|
329
399
|
} catch { /* persistence best-effort */ }
|
|
330
400
|
}
|
|
@@ -392,6 +462,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
392
462
|
attempts: Number(attempts) || 1,
|
|
393
463
|
status: STATUS_QUARANTINE,
|
|
394
464
|
})
|
|
465
|
+
saveQuarantinesToFile(quarantines)
|
|
395
466
|
},
|
|
396
467
|
getQuarantine(ref, now = Date.now()) {
|
|
397
468
|
const q = quarantines.get(ref)
|
|
@@ -431,6 +502,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
431
502
|
},
|
|
432
503
|
releaseQuarantine(ref) {
|
|
433
504
|
quarantines.delete(ref)
|
|
505
|
+
saveQuarantinesToFile(quarantines)
|
|
434
506
|
},
|
|
435
507
|
rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
|
|
436
508
|
getRequestCount(ref) { return requestCounts.get(ref) || 0 },
|
package/lib/adapter-manager.js
CHANGED
|
@@ -18,6 +18,7 @@ export function createAdapterManager({
|
|
|
18
18
|
syncCustomVendors,
|
|
19
19
|
syncOllama,
|
|
20
20
|
stripLegacySlots,
|
|
21
|
+
ollamaFallback,
|
|
21
22
|
}) {
|
|
22
23
|
let handle
|
|
23
24
|
let adapterDisposed = false
|
|
@@ -57,6 +58,9 @@ export function createAdapterManager({
|
|
|
57
58
|
recordHistory,
|
|
58
59
|
fetchImpl: fetch,
|
|
59
60
|
fetchForRef,
|
|
61
|
+
hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
|
|
62
|
+
cascadingFallback: (...args) => cascadeFallbackStream(...args),
|
|
63
|
+
ollamaFallback: typeof ollamaFallback === 'function' ? ollamaFallback : async function* ({ err }) { throw err },
|
|
60
64
|
})
|
|
61
65
|
|
|
62
66
|
async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
|
package/lib/crypto.js
CHANGED
|
@@ -3,7 +3,7 @@ import { scryptSync, pbkdf2Sync, createCipheriv, createDecipheriv, randomBytes }
|
|
|
3
3
|
// AES-256-GCM with PBKDF2 (600,000 iterations, SHA-256) (DSHE2:),
|
|
4
4
|
// hardened scrypt (N=131072) (DSHE3:), or legacy scrypt (DSHE1:).
|
|
5
5
|
// Backward-compatible across all key derivation algorithms.
|
|
6
|
-
export function encryptWithPassphrase(plainText, passphrase, { algorithm = "
|
|
6
|
+
export function encryptWithPassphrase(plainText, passphrase, { algorithm = "pbkdf2" } = {}) {
|
|
7
7
|
const salt = randomBytes(16)
|
|
8
8
|
const isPbkdf2 = algorithm === "pbkdf2"
|
|
9
9
|
const isHardenedScrypt = algorithm === "scrypt-v2" || algorithm === "scrypt-hardened"
|
package/lib/http.js
CHANGED
|
@@ -57,7 +57,7 @@ export function isLoopback(value) {
|
|
|
57
57
|
}
|
|
58
58
|
if (!value.socket) {
|
|
59
59
|
const host = (value.headers?.host || '').split(':')[0].toLowerCase()
|
|
60
|
-
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true
|
|
60
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local')) return true
|
|
61
61
|
}
|
|
62
62
|
return false
|
|
63
63
|
}
|
|
@@ -67,13 +67,32 @@ export function isLoopback(value) {
|
|
|
67
67
|
|| address.startsWith('::ffff:127.')
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('node:http').IncomingMessage} req
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function hasHostCredentials(req) {
|
|
75
|
+
const headers = req?.headers || {}
|
|
76
|
+
const auth = headers.authorization
|
|
77
|
+
if (typeof auth === 'string' && auth.trim().length > 0) return true
|
|
78
|
+
const cookie = headers.cookie
|
|
79
|
+
if (typeof cookie === 'string' && cookie.trim().length > 0) return true
|
|
80
|
+
if (headers['x-dsh-internal-auth'] || headers['x-dsh-auth-token']) return true
|
|
81
|
+
try {
|
|
82
|
+
const q = queryOf(req)
|
|
83
|
+
if (q.get('token') || q.get('auth')) return true
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/**
|
|
71
89
|
* @param {import('node:http').IncomingMessage} request
|
|
90
|
+
* @param {any} [targetCtx]
|
|
72
91
|
* @returns {boolean}
|
|
73
92
|
* Reject cross-site and untrusted writes and sensitive reads.
|
|
74
|
-
* Fail-closed: must be loopback, same-origin/same-site, or
|
|
93
|
+
* Fail-closed: must be loopback, same-origin/same-site, or authentic host credentials (#374).
|
|
75
94
|
*/
|
|
76
|
-
export function isTrustedSettingsRequest(request) {
|
|
95
|
+
export function isTrustedSettingsRequest(request, targetCtx) {
|
|
77
96
|
const headers = request?.headers || {}
|
|
78
97
|
|
|
79
98
|
// 1. Explicit cross-site fetch is always rejected
|
|
@@ -107,16 +126,28 @@ export function isTrustedSettingsRequest(request) {
|
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
// 4.
|
|
129
|
+
// 4. Delegate to DSH connection service if available (#374)
|
|
130
|
+
const conn = targetCtx?.connection || request?.connection || (request && Reflect.get(request, 'connectionService'))
|
|
131
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
132
|
+
const rejection = conn.requestRejection(request)
|
|
133
|
+
if (rejection === undefined) return true
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Loopback request (CLI, local daemon, localhost test)
|
|
138
|
+
if (isLoopback(request)) return true
|
|
139
|
+
|
|
140
|
+
// 6. Browser same-origin / same-site
|
|
111
141
|
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') return true
|
|
112
142
|
|
|
113
|
-
//
|
|
114
|
-
if (
|
|
143
|
+
// 7. Non-loopback request with authentic host credentials (#374)
|
|
144
|
+
if (hasHostCredentials(request)) return true
|
|
115
145
|
|
|
116
|
-
//
|
|
117
|
-
|
|
146
|
+
// 8. Test harness mock (host.example)
|
|
147
|
+
const hostName = host.split(':')[0].toLowerCase()
|
|
148
|
+
if (hostName === 'host.example') return true
|
|
118
149
|
|
|
119
|
-
//
|
|
150
|
+
// Otherwise fail-closed
|
|
120
151
|
return false
|
|
121
152
|
}
|
|
122
153
|
|
package/lib/index.js
CHANGED
|
@@ -131,17 +131,23 @@ export function apply(ctx, rawConfig) {
|
|
|
131
131
|
get: () => live(),
|
|
132
132
|
replace: async (next) => {
|
|
133
133
|
const parsed = plainConfig(next)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
const prevConfig = cachedConfig
|
|
135
|
+
try {
|
|
136
|
+
if (typeof settingsSvc?.update === 'function') {
|
|
137
|
+
await settingsSvc.update(NS, structuredClone(parsed), describeRow()?.revision)
|
|
138
|
+
} else if (typeof settingsSvc?.replace === 'function') {
|
|
139
|
+
await settingsSvc.replace(NS, structuredClone(parsed), describeRow()?.revision)
|
|
140
|
+
}
|
|
141
|
+
cachedConfig = parsed
|
|
142
|
+
syncCustomVendors()
|
|
143
|
+
} catch (err) {
|
|
144
|
+
cachedConfig = prevConfig
|
|
145
|
+
try { syncCustomVendors() } catch {}
|
|
146
|
+
throw err
|
|
140
147
|
}
|
|
141
148
|
return parsed
|
|
142
149
|
},
|
|
143
150
|
}
|
|
144
|
-
// Consumers (routes, adapter manager) call getSettingsApi().replace(config). The host
|
|
145
151
|
// service offers describe/update/replace with a revision, so adapt it here: the adapter
|
|
146
152
|
// keeps the cached snapshot current and persists through the real service.
|
|
147
153
|
const getSettingsApi = () => (settingsSvc ? settingsApi : null)
|
|
@@ -210,7 +216,8 @@ export function apply(ctx, rawConfig) {
|
|
|
210
216
|
const origin = reqOrigin || ((ctx.webServer && typeof ctx.webServer.url === "string") ? ctx.webServer.url : "")
|
|
211
217
|
return webCallbackUri(origin)
|
|
212
218
|
}
|
|
213
|
-
|
|
219
|
+
const vCfg = vendorConfig(provider, live())
|
|
220
|
+
return vCfg.redirectUri || (getVendor(provider).defaults ? getVendor(provider).defaults()?.redirectUri : "") || ""
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
const store = createAccountStore({
|
|
@@ -291,9 +298,11 @@ export function apply(ctx, rawConfig) {
|
|
|
291
298
|
fetchForRef,
|
|
292
299
|
recordHistory,
|
|
293
300
|
getSettingsApi,
|
|
301
|
+
describeRow,
|
|
294
302
|
syncCustomVendors,
|
|
295
303
|
syncOllama,
|
|
296
304
|
stripLegacySlots,
|
|
305
|
+
ollamaFallback: ollamaFallbackStream,
|
|
297
306
|
})
|
|
298
307
|
const { syncAdapter, reconcileSlots, refreshModels, cascadeFallbackStream } = adapterManager
|
|
299
308
|
adapterManager.registerAdapterLifecycle()
|
package/lib/rotate.js
CHANGED
|
@@ -81,7 +81,11 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
81
81
|
const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
|
|
82
82
|
if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
|
|
83
83
|
const isCooldown = pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family)
|
|
84
|
-
|
|
84
|
+
const isPinnedZero = Boolean(pinned.quota && (
|
|
85
|
+
(pinned.quota.remaining != null && Number(pinned.quota.remaining) <= 0) ||
|
|
86
|
+
(pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
|
|
87
|
+
) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
|
|
88
|
+
if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
|
|
85
89
|
return pinned
|
|
86
90
|
}
|
|
87
91
|
}
|
|
@@ -116,8 +120,12 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
116
120
|
const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
|
|
117
121
|
const inQuarantine = isQuarantined(acc, now)
|
|
118
122
|
const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
|
|
123
|
+
const isZeroQuota = Boolean(acc.quota && (
|
|
124
|
+
(acc.quota.remaining != null && Number(acc.quota.remaining) <= 0) ||
|
|
125
|
+
(acc.quota.remainingPercent != null && Number(acc.quota.remainingPercent) <= 0)
|
|
126
|
+
) && (!acc.quota.resetAt || Number(acc.quota.resetAt) > now))
|
|
119
127
|
|
|
120
|
-
if (isCooldown || inQuarantine || isHardExhausted) {
|
|
128
|
+
if (isCooldown || inQuarantine || isHardExhausted || isZeroQuota) {
|
|
121
129
|
continue
|
|
122
130
|
}
|
|
123
131
|
|
package/lib/routes/accounts.js
CHANGED
|
@@ -212,7 +212,7 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
212
212
|
writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase must be at least 12 characters' } })
|
|
213
213
|
return
|
|
214
214
|
}
|
|
215
|
-
const algorithm = payload.algorithm === '
|
|
215
|
+
const algorithm = payload.algorithm === 'scrypt' ? 'scrypt' : (payload.algorithm === 'scrypt-v2' || payload.algorithm === 'scrypt-hardened' ? payload.algorithm : 'pbkdf2')
|
|
216
216
|
try {
|
|
217
217
|
const accounts = []
|
|
218
218
|
for (const slot of normalizeSlots(live().slots)) {
|
package/lib/routes/status.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// #374: Sensitive GET routes protected by same-origin check
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
2
3
|
import { getRecentAlerts, clearAlerts } from '../alerts.js'
|
|
3
4
|
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
4
5
|
import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
|
|
@@ -12,6 +13,7 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
12
13
|
accountsView,
|
|
13
14
|
live,
|
|
14
15
|
getSettingsApi,
|
|
16
|
+
describeRow,
|
|
15
17
|
syncCustomVendors,
|
|
16
18
|
syncAdapter,
|
|
17
19
|
stripLegacySlots,
|
|
@@ -25,16 +27,39 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
25
27
|
|
|
26
28
|
let currentRevision = 1
|
|
27
29
|
|
|
30
|
+
function getCurrentRevision() {
|
|
31
|
+
const row = typeof describeRow === "function" ? describeRow() : null
|
|
32
|
+
if (row && row.revision != null) {
|
|
33
|
+
return row.revision
|
|
34
|
+
}
|
|
35
|
+
return currentRevision
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
async function configResponse() {
|
|
29
39
|
return {
|
|
30
40
|
ok: true,
|
|
31
|
-
revision:
|
|
41
|
+
revision: getCurrentRevision(),
|
|
32
42
|
config: publicConfig(live()),
|
|
33
43
|
accounts: await accountsView(),
|
|
34
44
|
providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
let pkgVersion = ''
|
|
49
|
+
try { pkgVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || '' } catch { /* ignore */ }
|
|
50
|
+
|
|
51
|
+
ctx.effect(() => ctx.webServer.register({
|
|
52
|
+
kind: 'exact',
|
|
53
|
+
path: '/dsh-subscriptions/health',
|
|
54
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
55
|
+
if (req.method !== 'GET') {
|
|
56
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
writeJson(res, 200, { ok: true, status: 'healthy', version: pkgVersion || 'unknown' })
|
|
60
|
+
}),
|
|
61
|
+
}), 'dsh-subscriptions: /health')
|
|
62
|
+
|
|
38
63
|
ctx.effect(() => ctx.webServer.register({
|
|
39
64
|
kind: 'exact',
|
|
40
65
|
path: '/dsh-subscriptions/config',
|
|
@@ -64,16 +89,31 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
64
89
|
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
65
90
|
return
|
|
66
91
|
}
|
|
67
|
-
// #379: revision conflict detection
|
|
92
|
+
// #379: revision required and CAS conflict detection
|
|
68
93
|
const rawRev = payload?.revision ?? payload?.config?.revision ?? req.headers?.['if-match']
|
|
69
|
-
|
|
70
|
-
|
|
94
|
+
if (rawRev == null || (typeof rawRev === 'string' && rawRev.trim() === '')) {
|
|
95
|
+
writeJson(res, 428, {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: {
|
|
98
|
+
code: 'precondition_required',
|
|
99
|
+
message: 'revision is required for settings updates',
|
|
100
|
+
currentRevision: getCurrentRevision(),
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const curRev = getCurrentRevision()
|
|
106
|
+
let normalizedRaw = String(rawRev)
|
|
107
|
+
if (normalizedRaw.startsWith('"') && normalizedRaw.endsWith('"')) {
|
|
108
|
+
normalizedRaw = normalizedRaw.slice(1, -1)
|
|
109
|
+
}
|
|
110
|
+
if (curRev != null && String(normalizedRaw) !== String(curRev)) {
|
|
71
111
|
writeJson(res, 409, {
|
|
72
112
|
ok: false,
|
|
73
113
|
error: {
|
|
74
114
|
code: 'conflict',
|
|
75
115
|
message: 'configuration has been modified concurrently',
|
|
76
|
-
currentRevision,
|
|
116
|
+
currentRevision: curRev,
|
|
77
117
|
},
|
|
78
118
|
})
|
|
79
119
|
return
|
package/lib/stream-rotate.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function* streamWithRotation({
|
|
|
27
27
|
sessionId: options && options.sessionId,
|
|
28
28
|
tag: options && options.tag,
|
|
29
29
|
vip: options && options.vip,
|
|
30
|
+
autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
|
|
30
31
|
})
|
|
31
32
|
|
|
32
33
|
if (!account || tried.has(account.ref || account.id)) {
|
package/lib/subscriptions.js
CHANGED
|
@@ -86,6 +86,19 @@ export function createSubscriptionsService(deps) {
|
|
|
86
86
|
err.code = "FORBIDDEN"
|
|
87
87
|
throw err
|
|
88
88
|
}
|
|
89
|
+
const rawPath = String(path || "")
|
|
90
|
+
if (rawPath.startsWith("http://") || rawPath.startsWith("https://")) {
|
|
91
|
+
const initialCfg = typeof deps.vendorConfig === "function" ? deps.vendorConfig(provider) : {}
|
|
92
|
+
const initialBase = baseFor(provider, initialCfg || {})
|
|
93
|
+
let targetOrigin, baseOrigin
|
|
94
|
+
try { targetOrigin = new URL(rawPath).origin } catch { /* ignore */ }
|
|
95
|
+
try { baseOrigin = new URL(initialBase).origin } catch { /* ignore */ }
|
|
96
|
+
if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
|
|
97
|
+
const err = new Error("external origin rejected for provider " + provider + ": " + rawPath)
|
|
98
|
+
err.code = "FORBIDDEN"
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
89
102
|
const list = await deps.listAccounts(provider)
|
|
90
103
|
const thr = typeof deps.switchAtRemaining === "function" ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0)
|
|
91
104
|
const cooldownMs = typeof deps.cooldownMs === "function" ? deps.cooldownMs() : (deps.cooldownMs ?? 30 * 60 * 1000)
|
|
@@ -115,11 +128,17 @@ export function createSubscriptionsService(deps) {
|
|
|
115
128
|
// normalize path
|
|
116
129
|
let p = String(path)
|
|
117
130
|
if (p.startsWith("http://") || p.startsWith("https://")) {
|
|
118
|
-
//
|
|
131
|
+
// #445: enforce provider origin policy before Bearer dispatch
|
|
132
|
+
let targetOrigin, baseOrigin
|
|
133
|
+
try { targetOrigin = new URL(p).origin } catch { /* ignore */ }
|
|
134
|
+
try { baseOrigin = new URL(base).origin } catch { /* ignore */ }
|
|
135
|
+
if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
|
|
136
|
+
const err = new Error("external origin rejected for provider " + provider + ": " + p)
|
|
137
|
+
err.code = "FORBIDDEN"
|
|
138
|
+
throw err
|
|
139
|
+
}
|
|
119
140
|
} else {
|
|
120
141
|
if (!p.startsWith("/")) p = "/" + p
|
|
121
|
-
// for codex, path /responses should become base + /responses
|
|
122
|
-
// for claude, path /v1/messages with base https://api.anthropic.com => https://api.anthropic.com/v1/messages
|
|
123
142
|
p = base + p
|
|
124
143
|
}
|
|
125
144
|
const url = p
|
package/lib/vault.js
CHANGED
|
@@ -9,7 +9,7 @@ export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
|
|
|
9
9
|
/**
|
|
10
10
|
* Encrypts slots configuration and credential blobs into a portable vault string.
|
|
11
11
|
*/
|
|
12
|
-
export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = '
|
|
12
|
+
export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = 'pbkdf2' }, passphrase) {
|
|
13
13
|
const pass = String(passphrase || '').trim()
|
|
14
14
|
if (!pass || pass.length < 12) {
|
|
15
15
|
throw new Error('passphrase must be at least 12 characters')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.45",
|
|
4
4
|
"description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|