@goodandready/dsh-subscriptions 0.6.43 → 0.6.44
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 +29 -4
- package/lib/adapter-manager.js +1 -0
- package/lib/crypto.js +1 -1
- package/lib/http.js +40 -9
- package/lib/index.js +16 -8
- package/lib/routes/accounts.js +1 -1
- package/lib/routes/status.js +45 -5
- package/lib/subscriptions.js +22 -3
- package/lib/vault.js +1 -1
- package/package.json +1 -1
package/lib/accounts.js
CHANGED
|
@@ -64,6 +64,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
64
64
|
const quotas = new Map()
|
|
65
65
|
const quarantines = new Map()
|
|
66
66
|
const writeQueues = new Map()
|
|
67
|
+
const credentialVersions = new Map()
|
|
68
|
+
const clearedRefs = new Set()
|
|
67
69
|
const refreshLocks = new Map()
|
|
68
70
|
const refreshFailures = new Map()
|
|
69
71
|
const usage = new Map()
|
|
@@ -98,11 +100,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
98
100
|
|
|
99
101
|
async function saveBlob(ref, blob) {
|
|
100
102
|
if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
|
|
103
|
+
clearedRefs.delete(ref)
|
|
104
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
105
|
+
credentialVersions.set(ref, version)
|
|
106
|
+
|
|
101
107
|
const prev = writeQueues.get(ref) || Promise.resolve()
|
|
102
108
|
const next = prev.catch(() => {}).then(async () => {
|
|
109
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
103
112
|
try {
|
|
104
113
|
await credentials.set(credentialRef(ref), serializeBlob(blob))
|
|
114
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
115
|
+
try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
|
|
116
|
+
}
|
|
105
117
|
} catch (err) {
|
|
118
|
+
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
106
121
|
const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
|
|
107
122
|
wrapErr.code = "CREDENTIAL_SAVE_FAILED"
|
|
108
123
|
wrapErr.cause = err
|
|
@@ -115,6 +130,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
115
130
|
|
|
116
131
|
async function clearRef(ref) {
|
|
117
132
|
if (!ref || typeof ref !== "string") return
|
|
133
|
+
clearedRefs.add(ref)
|
|
134
|
+
const version = (credentialVersions.get(ref) || 0) + 1
|
|
135
|
+
credentialVersions.set(ref, version)
|
|
118
136
|
try {
|
|
119
137
|
await credentials.unset(credentialRef(ref))
|
|
120
138
|
} catch { /* best-effort unset */ }
|
|
@@ -320,11 +338,18 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
320
338
|
windows.set(slot.ref, list)
|
|
321
339
|
// persist last known windows so the card survives restarts
|
|
322
340
|
try {
|
|
323
|
-
|
|
341
|
+
if (clearedRefs.has(slot.ref)) return
|
|
342
|
+
let currentBlob = null
|
|
343
|
+
try {
|
|
344
|
+
const currentRaw = await resolveRaw(slot.ref)
|
|
345
|
+
if (currentRaw) currentBlob = parseBlob(currentRaw)
|
|
346
|
+
} catch { /* best-effort */ }
|
|
347
|
+
if (!currentBlob || clearedRefs.has(slot.ref)) return
|
|
348
|
+
const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
|
|
324
349
|
if (prev !== JSON.stringify(list)) {
|
|
325
|
-
await saveBlob(slot.ref, { ...
|
|
326
|
-
} else if (!
|
|
327
|
-
await saveBlob(slot.ref, { ...
|
|
350
|
+
await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
|
|
351
|
+
} else if (!currentBlob.usageAt) {
|
|
352
|
+
await saveBlob(slot.ref, { ...currentBlob, usageAt: Date.now() })
|
|
328
353
|
}
|
|
329
354
|
} catch { /* persistence best-effort */ }
|
|
330
355
|
}
|
package/lib/adapter-manager.js
CHANGED
|
@@ -57,6 +57,7 @@ export function createAdapterManager({
|
|
|
57
57
|
recordHistory,
|
|
58
58
|
fetchImpl: fetch,
|
|
59
59
|
fetchForRef,
|
|
60
|
+
hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
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,6 +298,7 @@ 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,
|
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/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.44",
|
|
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",
|