@goodandready/dsh-subscriptions 0.6.41 → 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 +47 -12
- package/lib/adapter-manager.js +1 -0
- package/lib/adapter.js +8 -4
- package/lib/backoff.js +13 -1
- package/lib/code-assist.js +1 -0
- package/lib/crypto.js +1 -1
- package/lib/google-validation.js +1 -1
- package/lib/http.js +130 -15
- package/lib/import-auth.js +91 -44
- package/lib/index.js +16 -8
- package/lib/oauth.js +12 -0
- package/lib/routes/accounts.js +6 -5
- package/lib/routes/status.js +51 -13
- package/lib/runaway-guard.js +5 -0
- package/lib/session-pin.js +9 -4
- package/lib/sse.js +6 -1
- package/lib/stream-rotate.js +1 -1
- package/lib/subscriptions.js +22 -3
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -4
- package/lib/vendors/claude-cli.js +2 -1
- package/lib/vendors/codex.js +4 -2
- package/lib/vendors/copilot.js +1 -0
- package/lib/vendors/cursor.js +1 -0
- package/lib/vendors/glm.js +1 -0
- package/lib/vendors/kimi.js +1 -0
- package/lib/vendors/kiro.js +1 -0
- package/lib/vendors/qwen.js +1 -0
- package/lib/wire.js +12 -11
- package/package.json +13 -8
package/lib/import-auth.js
CHANGED
|
@@ -39,6 +39,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
39
39
|
const tok = codex.tokens || codex
|
|
40
40
|
detected.codex = {
|
|
41
41
|
provider: 'codex',
|
|
42
|
+
source: 'file',
|
|
42
43
|
path: homeFile('.codex', 'auth.json'),
|
|
43
44
|
email: codex.email || codex.account || '',
|
|
44
45
|
hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
|
|
@@ -54,6 +55,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
54
55
|
if (grok && (grok.access_token || grok.token || (grok.tokens && grok.tokens.access_token))) {
|
|
55
56
|
detected.grok = {
|
|
56
57
|
provider: 'grok',
|
|
58
|
+
source: 'file',
|
|
57
59
|
path: p,
|
|
58
60
|
email: grok.email || grok.account || '',
|
|
59
61
|
hasRefreshToken: !!(grok.refresh_token || (grok.tokens && grok.tokens.refresh_token)),
|
|
@@ -73,6 +75,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
73
75
|
if (tok && (tok.access_token || tok.accessToken)) {
|
|
74
76
|
detected.antigravity = {
|
|
75
77
|
provider: 'antigravity',
|
|
78
|
+
source: 'file',
|
|
76
79
|
path: p,
|
|
77
80
|
email: agy.email || tok.account || '',
|
|
78
81
|
hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
|
|
@@ -86,6 +89,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
86
89
|
if (kimi && (kimi.access_token || kimi.token)) {
|
|
87
90
|
detected.kimi = {
|
|
88
91
|
provider: 'kimi',
|
|
92
|
+
source: 'file',
|
|
89
93
|
path: homeFile('.kimi-code', 'credentials', 'kimi-code.json'),
|
|
90
94
|
email: kimi.email || kimi.account || '',
|
|
91
95
|
hasRefreshToken: !!(kimi.refresh_token || kimi.refreshToken),
|
|
@@ -103,6 +107,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
103
107
|
if (glm && (glm.apiKey || glm.api_key || (glm.provider && glm.provider.apiKey))) {
|
|
104
108
|
detected.glm = {
|
|
105
109
|
provider: 'glm',
|
|
110
|
+
source: 'file',
|
|
106
111
|
path: p,
|
|
107
112
|
email: 'ZCode CLI Account',
|
|
108
113
|
hasRefreshToken: false,
|
|
@@ -111,20 +116,31 @@ async function discoverLocalCliSessionsInner() {
|
|
|
111
116
|
}
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
// 6. Cursor Token (
|
|
119
|
+
// 6. Cursor Token (CLI config, auth.json, or env)
|
|
115
120
|
const cursorCliConfig = await readJson(homeFile('.cursor', 'cli-config.json'))
|
|
116
|
-
|
|
121
|
+
const cursorAuthConfig = await readJson(homeFile('.cursor', 'auth.json'))
|
|
122
|
+
if (cursorCliConfig && (cursorCliConfig.authInfo || cursorCliConfig.serverConfigCache)) {
|
|
117
123
|
detected.cursor = {
|
|
118
124
|
provider: 'cursor',
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
source: 'file',
|
|
126
|
+
path: homeFile('.cursor', 'cli-config.json'),
|
|
127
|
+
email: (cursorCliConfig.authInfo && cursorCliConfig.authInfo.email) || 'Cursor CLI User',
|
|
121
128
|
hasRefreshToken: false,
|
|
122
129
|
}
|
|
123
|
-
} else if (
|
|
130
|
+
} else if (cursorAuthConfig && (cursorAuthConfig.accessToken || cursorAuthConfig.access_token || (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.authId))) {
|
|
124
131
|
detected.cursor = {
|
|
125
132
|
provider: 'cursor',
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
source: 'file',
|
|
134
|
+
path: homeFile('.cursor', 'auth.json'),
|
|
135
|
+
email: (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.email) || cursorAuthConfig.email || 'Cursor User',
|
|
136
|
+
hasRefreshToken: false,
|
|
137
|
+
}
|
|
138
|
+
} else if (process.env.CURSOR_ACCESS_TOKEN) {
|
|
139
|
+
detected.cursor = {
|
|
140
|
+
provider: 'cursor',
|
|
141
|
+
source: 'env',
|
|
142
|
+
path: 'CURSOR_ACCESS_TOKEN',
|
|
143
|
+
email: 'Cursor IDE User',
|
|
128
144
|
hasRefreshToken: false,
|
|
129
145
|
}
|
|
130
146
|
}
|
|
@@ -139,6 +155,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
139
155
|
if (k && (k.accessToken || k.access_token || k.token)) {
|
|
140
156
|
detected.kiro = {
|
|
141
157
|
provider: 'kiro',
|
|
158
|
+
source: 'file',
|
|
142
159
|
path: p,
|
|
143
160
|
email: k.email || k.account || 'AWS Kiro User',
|
|
144
161
|
hasRefreshToken: !!(k.refreshToken || k.refresh_token),
|
|
@@ -149,7 +166,8 @@ async function discoverLocalCliSessionsInner() {
|
|
|
149
166
|
if (!detected.kiro && process.env.KIRO_API_KEY) {
|
|
150
167
|
detected.kiro = {
|
|
151
168
|
provider: 'kiro',
|
|
152
|
-
|
|
169
|
+
source: 'env',
|
|
170
|
+
path: 'KIRO_API_KEY',
|
|
153
171
|
email: 'AWS Kiro API Key',
|
|
154
172
|
hasRefreshToken: false,
|
|
155
173
|
}
|
|
@@ -158,12 +176,15 @@ async function discoverLocalCliSessionsInner() {
|
|
|
158
176
|
// 8. Claude CLI Direct Import
|
|
159
177
|
const claudeCreds = await readClaudeCredentials(activeHome)
|
|
160
178
|
if (claudeCreds) {
|
|
161
|
-
|
|
179
|
+
const claudeEntry = {
|
|
162
180
|
provider: 'claude',
|
|
181
|
+
source: 'file',
|
|
163
182
|
path: homeFile('.claude', 'credentials.json'),
|
|
164
|
-
email: 'Claude Code CLI User',
|
|
183
|
+
email: claudeCreds.email || 'Claude Code CLI User',
|
|
165
184
|
hasRefreshToken: !!claudeCreds.refreshToken,
|
|
166
185
|
}
|
|
186
|
+
detected.claude = claudeEntry
|
|
187
|
+
detected['claude-cli'] = claudeEntry
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
// 9. GitHub Copilot token
|
|
@@ -179,6 +200,7 @@ async function discoverLocalCliSessionsInner() {
|
|
|
179
200
|
if (tok) {
|
|
180
201
|
detected.copilot = {
|
|
181
202
|
provider: 'copilot',
|
|
203
|
+
source: 'file',
|
|
182
204
|
path: p,
|
|
183
205
|
email: gh.user || 'GitHub Copilot User',
|
|
184
206
|
hasRefreshToken: false,
|
|
@@ -190,37 +212,37 @@ async function discoverLocalCliSessionsInner() {
|
|
|
190
212
|
if (!detected.copilot && (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN)) {
|
|
191
213
|
detected.copilot = {
|
|
192
214
|
provider: 'copilot',
|
|
193
|
-
|
|
215
|
+
source: 'env',
|
|
216
|
+
path: process.env.GITHUB_COPILOT_TOKEN ? 'GITHUB_COPILOT_TOKEN' : 'GH_TOKEN',
|
|
194
217
|
email: 'GitHub Copilot User',
|
|
195
218
|
hasRefreshToken: false,
|
|
196
219
|
}
|
|
197
220
|
}
|
|
198
221
|
|
|
199
|
-
|
|
200
|
-
|
|
201
222
|
return detected
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
export async function loadLocalCliBlob(provider, options = {}) {
|
|
226
|
+
const canonical = (provider === 'claude-cli') ? 'claude' : provider
|
|
205
227
|
const discovered = await discoverLocalCliSessions(options)
|
|
206
|
-
const info = discovered[provider]
|
|
228
|
+
const info = discovered[canonical] || discovered[provider]
|
|
207
229
|
if (!info) throw new Error(`no local CLI session found for ${provider}`)
|
|
208
230
|
|
|
209
|
-
if (
|
|
231
|
+
if (canonical === 'claude') {
|
|
210
232
|
const creds = await readClaudeCredentials(options && options.home)
|
|
211
233
|
if (!creds) throw new Error('Claude CLI credentials not found')
|
|
212
234
|
return {
|
|
213
235
|
accessToken: creds.accessToken,
|
|
214
|
-
refreshToken: creds.refreshToken,
|
|
236
|
+
refreshToken: creds.refreshToken || '',
|
|
215
237
|
expiresAt: creds.expiresAt,
|
|
216
|
-
email: 'Claude Code CLI User'
|
|
238
|
+
email: creds.email || 'Claude Code CLI User',
|
|
217
239
|
}
|
|
218
240
|
}
|
|
219
241
|
|
|
220
|
-
if (
|
|
242
|
+
if (canonical === 'copilot') {
|
|
221
243
|
let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
|
|
222
244
|
let email = 'GitHub Copilot User'
|
|
223
|
-
if (info.
|
|
245
|
+
if (info.source === 'file' && info.path) {
|
|
224
246
|
const cp = await readJson(info.path)
|
|
225
247
|
if (cp) {
|
|
226
248
|
const gh = cp['github.com'] || cp
|
|
@@ -228,6 +250,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
228
250
|
email = gh.user || email
|
|
229
251
|
}
|
|
230
252
|
}
|
|
253
|
+
if (!tok) throw new Error('no Copilot token found')
|
|
231
254
|
return {
|
|
232
255
|
accessToken: tok,
|
|
233
256
|
refreshToken: tok,
|
|
@@ -238,10 +261,54 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
238
261
|
}
|
|
239
262
|
}
|
|
240
263
|
|
|
264
|
+
if (canonical === 'cursor') {
|
|
265
|
+
if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'CURSOR_ACCESS_TOKEN') {
|
|
266
|
+
const token = process.env.CURSOR_ACCESS_TOKEN
|
|
267
|
+
if (!token) throw new Error('CURSOR_ACCESS_TOKEN environment variable not set')
|
|
268
|
+
return {
|
|
269
|
+
accessToken: token,
|
|
270
|
+
refreshToken: '',
|
|
271
|
+
expiresAt: Date.now() + 30 * 86400 * 1000,
|
|
272
|
+
email: 'Cursor IDE User',
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const raw = await readJson(info.path)
|
|
276
|
+
if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
|
|
277
|
+
const token = (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || process.env.CURSOR_ACCESS_TOKEN || ''
|
|
278
|
+
return {
|
|
279
|
+
accessToken: token,
|
|
280
|
+
refreshToken: '',
|
|
281
|
+
expiresAt: Date.now() + 30 * 86400 * 1000,
|
|
282
|
+
email: (raw && raw.authInfo && raw.authInfo.email) || raw.email || 'Cursor User',
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (canonical === 'kiro') {
|
|
287
|
+
if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'KIRO_API_KEY') {
|
|
288
|
+
const token = process.env.KIRO_API_KEY
|
|
289
|
+
if (!token) throw new Error('KIRO_API_KEY environment variable not set')
|
|
290
|
+
return {
|
|
291
|
+
accessToken: token,
|
|
292
|
+
refreshToken: '',
|
|
293
|
+
expiresAt: Date.now() + 30 * 86400 * 1000,
|
|
294
|
+
email: 'AWS Kiro API Key',
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const raw = await readJson(info.path)
|
|
298
|
+
if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
|
|
299
|
+
const token = (raw && (raw.accessToken || raw.access_token || raw.token)) || process.env.KIRO_API_KEY || ''
|
|
300
|
+
return {
|
|
301
|
+
accessToken: token,
|
|
302
|
+
refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
|
|
303
|
+
expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
|
|
304
|
+
email: (raw && raw.email) || 'AWS Kiro User',
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
241
308
|
const raw = await readJson(info.path)
|
|
242
309
|
if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
|
|
243
310
|
|
|
244
|
-
if (
|
|
311
|
+
if (canonical === 'codex') {
|
|
245
312
|
const tok = raw.tokens || raw
|
|
246
313
|
return {
|
|
247
314
|
accessToken: tok.access_token || tok.accessToken || '',
|
|
@@ -252,7 +319,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
252
319
|
}
|
|
253
320
|
}
|
|
254
321
|
|
|
255
|
-
if (
|
|
322
|
+
if (canonical === 'grok') {
|
|
256
323
|
const tok = raw.tokens || raw
|
|
257
324
|
return {
|
|
258
325
|
accessToken: tok.access_token || tok.token || '',
|
|
@@ -262,7 +329,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
262
329
|
}
|
|
263
330
|
}
|
|
264
331
|
|
|
265
|
-
if (
|
|
332
|
+
if (canonical === 'antigravity') {
|
|
266
333
|
const tok = raw.token || raw
|
|
267
334
|
return {
|
|
268
335
|
accessToken: tok.access_token || tok.accessToken || '',
|
|
@@ -273,7 +340,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
273
340
|
}
|
|
274
341
|
}
|
|
275
342
|
|
|
276
|
-
if (
|
|
343
|
+
if (canonical === 'kimi') {
|
|
277
344
|
return {
|
|
278
345
|
accessToken: raw.access_token || raw.token || '',
|
|
279
346
|
refreshToken: raw.refresh_token || '',
|
|
@@ -282,7 +349,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
282
349
|
}
|
|
283
350
|
}
|
|
284
351
|
|
|
285
|
-
if (
|
|
352
|
+
if (canonical === 'glm') {
|
|
286
353
|
const apiKey = raw.apiKey || raw.api_key || (raw.provider && raw.provider.apiKey) || ''
|
|
287
354
|
return {
|
|
288
355
|
accessToken: apiKey,
|
|
@@ -292,25 +359,5 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
292
359
|
}
|
|
293
360
|
}
|
|
294
361
|
|
|
295
|
-
if (provider === 'cursor') {
|
|
296
|
-
const token = process.env.CURSOR_ACCESS_TOKEN || (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || ''
|
|
297
|
-
return {
|
|
298
|
-
accessToken: token,
|
|
299
|
-
refreshToken: '',
|
|
300
|
-
expiresAt: Date.now() + 30 * 86400 * 1000,
|
|
301
|
-
email: (raw && raw.authInfo && raw.authInfo.email) || 'Cursor User',
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (provider === 'kiro') {
|
|
306
|
-
const token = process.env.KIRO_API_KEY || (raw && (raw.accessToken || raw.access_token || raw.token)) || ''
|
|
307
|
-
return {
|
|
308
|
-
accessToken: token,
|
|
309
|
-
refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
|
|
310
|
-
expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
|
|
311
|
-
email: (raw && raw.email) || 'AWS Kiro User',
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
362
|
throw new Error(`unsupported local CLI import for provider ${provider}`)
|
|
316
363
|
}
|
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/oauth.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{
|
|
3
|
+
* authUrl: string,
|
|
4
|
+
* clientId: string,
|
|
5
|
+
* redirectUri: string,
|
|
6
|
+
* challenge: string,
|
|
7
|
+
* state: string,
|
|
8
|
+
* extra?: Record<string, any>,
|
|
9
|
+
* scope?: string,
|
|
10
|
+
* }} params
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
1
13
|
export function buildAuthorizeUrl({
|
|
2
14
|
authUrl,
|
|
3
15
|
clientId,
|
package/lib/routes/accounts.js
CHANGED
|
@@ -143,7 +143,9 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
143
143
|
} catch {
|
|
144
144
|
body = null
|
|
145
145
|
}
|
|
146
|
-
|
|
146
|
+
const rawProv = body && body.provider
|
|
147
|
+
const prov = (rawProv === 'claude-cli') ? 'claude' : rawProv
|
|
148
|
+
if (!prov || !isProvider(prov)) {
|
|
147
149
|
writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'unknown or missing provider' } })
|
|
148
150
|
return
|
|
149
151
|
}
|
|
@@ -153,9 +155,8 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
153
155
|
return
|
|
154
156
|
}
|
|
155
157
|
try {
|
|
156
|
-
const blob = await loadLocalCliBlob(
|
|
157
|
-
const
|
|
158
|
-
const idx = Number(body.index) || 1
|
|
158
|
+
const blob = await loadLocalCliBlob(prov)
|
|
159
|
+
const idx = idxNum
|
|
159
160
|
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
160
161
|
const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
|
|
161
162
|
if (!exists && getSettingsApi()) {
|
|
@@ -211,7 +212,7 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
211
212
|
writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase must be at least 12 characters' } })
|
|
212
213
|
return
|
|
213
214
|
}
|
|
214
|
-
const algorithm = payload.algorithm === '
|
|
215
|
+
const algorithm = payload.algorithm === 'scrypt' ? 'scrypt' : (payload.algorithm === 'scrypt-v2' || payload.algorithm === 'scrypt-hardened' ? payload.algorithm : 'pbkdf2')
|
|
215
216
|
try {
|
|
216
217
|
const accounts = []
|
|
217
218
|
for (const slot of normalizeSlots(live().slots)) {
|
package/lib/routes/status.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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'
|
|
5
|
-
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
6
|
+
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson, fetchWithTimeout, isTimeoutError } from '../http.js'
|
|
6
7
|
import { quotaSnapshot } from '../ratelimit.js'
|
|
7
8
|
import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
|
|
8
9
|
import { getVendor } from '../vendors/index.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
|
|
@@ -257,10 +297,8 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
257
297
|
}
|
|
258
298
|
let capturedQuota = null
|
|
259
299
|
const probeFetch = async (url, init = {}) => {
|
|
260
|
-
const timeoutSignal = AbortSignal.timeout(15_000)
|
|
261
|
-
const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
|
|
262
300
|
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
263
|
-
const res2 = await doFetch
|
|
301
|
+
const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
|
|
264
302
|
try {
|
|
265
303
|
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
266
304
|
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
|
@@ -272,12 +310,14 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
272
310
|
const latencyMs = Date.now() - t0;
|
|
273
311
|
writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
|
|
274
312
|
} catch (e) {
|
|
313
|
+
const isTimeout = isTimeoutError(e)
|
|
275
314
|
writeJson(res, 200, {
|
|
276
315
|
ok: false, provider, index: index, ref,
|
|
277
316
|
latencyMs: Date.now() - t0,
|
|
278
317
|
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
279
318
|
quota: capturedQuota || info.quota || null,
|
|
280
|
-
|
|
319
|
+
isTimeout,
|
|
320
|
+
error: { code: isTimeout ? 'TIMEOUT' : (e && e.code ? e.code : 'VENDOR'), message: String(e && e.message || e).slice(0, 300) },
|
|
281
321
|
})
|
|
282
322
|
}
|
|
283
323
|
}),
|
|
@@ -384,10 +424,8 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
384
424
|
if (typeof vendor.check === 'function') {
|
|
385
425
|
let capturedQuota = null
|
|
386
426
|
const probeFetch = async (url, init = {}) => {
|
|
387
|
-
const timeoutSignal = AbortSignal.timeout(15_000)
|
|
388
|
-
const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
|
|
389
427
|
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
390
|
-
const res2 = await doFetch
|
|
428
|
+
const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
|
|
391
429
|
try {
|
|
392
430
|
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
393
431
|
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
package/lib/runaway-guard.js
CHANGED
|
@@ -68,6 +68,11 @@ export class RunawayDetector {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @param {AsyncIterable<any>} stream
|
|
73
|
+
* @param {Record<string, any>} [options]
|
|
74
|
+
* @returns {AsyncGenerator<any, void, unknown>}
|
|
75
|
+
*/
|
|
71
76
|
export async function* withRunawayGuard(stream, options = {}) {
|
|
72
77
|
if (options && options.enabled === false) {
|
|
73
78
|
yield* stream
|
package/lib/session-pin.js
CHANGED
|
@@ -2,10 +2,8 @@ const sessionPins = new Map()
|
|
|
2
2
|
const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* @param {
|
|
7
|
-
* @param {string|undefined} accountRef
|
|
8
|
-
* @param {number} [ttlMs]
|
|
5
|
+
* Prune expired session pin entries.
|
|
6
|
+
* @param {number} [now]
|
|
9
7
|
* @returns {void}
|
|
10
8
|
*/
|
|
11
9
|
export function pruneSessionPins(now = Date.now()) {
|
|
@@ -16,6 +14,13 @@ export function pruneSessionPins(now = Date.now()) {
|
|
|
16
14
|
}
|
|
17
15
|
}
|
|
18
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Bind a session id to an account ref for ttlMs (default 30 minutes).
|
|
19
|
+
* @param {string|undefined} sessionId
|
|
20
|
+
* @param {string|undefined} accountRef
|
|
21
|
+
* @param {number} [ttlMs]
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
19
24
|
export function pinSession(sessionId, accountRef, ttlMs = DEFAULT_TTL_MS) {
|
|
20
25
|
if (!sessionId || !accountRef) return
|
|
21
26
|
if (sessionPins.size >= 50) {
|
package/lib/sse.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {any} body
|
|
3
|
+
* @param {{ idleTimeoutMs?: number, signal?: AbortSignal }} [options]
|
|
4
|
+
* @returns {AsyncGenerator<string, void, unknown>}
|
|
5
|
+
*/
|
|
1
6
|
export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {}) {
|
|
2
7
|
const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
|
|
3
8
|
const decoder = new TextDecoder()
|
|
@@ -38,7 +43,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
|
|
|
38
43
|
const timeoutPromise = new Promise((_, reject) => {
|
|
39
44
|
if (!idleTimeoutMs || idleTimeoutMs <= 0) return
|
|
40
45
|
timer = setTimeout(() => {
|
|
41
|
-
const err = new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`)
|
|
46
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`))
|
|
42
47
|
err.code = 'TIMEOUT'
|
|
43
48
|
reject(err)
|
|
44
49
|
}, idleTimeoutMs)
|
package/lib/stream-rotate.js
CHANGED
|
@@ -41,7 +41,7 @@ export async function* streamWithRotation({
|
|
|
41
41
|
return
|
|
42
42
|
}
|
|
43
43
|
if (lastError) throw lastError
|
|
44
|
-
const err = new Error('no usable subscription account for this provider')
|
|
44
|
+
const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
45
45
|
err.code = 'RATE_LIMIT'
|
|
46
46
|
throw err
|
|
47
47
|
}
|
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')
|