@goodandready/dsh-subscriptions 0.6.72 → 0.6.83
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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/accounts-view.js +97 -5
- package/lib/accounts.js +137 -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 +22 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +344 -17
- package/lib/config-schema.js +50 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +392 -0
- package/lib/history.js +36 -0
- package/lib/http.js +67 -4
- package/lib/import-auth.js +3 -6
- package/lib/index.js +99 -11
- 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 +142 -7
- package/lib/routes/oauth.js +51 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/subscriptions.js +24 -1
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +414 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +13 -4
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +36 -5
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +136 -13
- package/lib/vendors/perplexity.js +151 -17
- package/package.json +1 -1
package/lib/http.js
CHANGED
|
@@ -79,7 +79,34 @@ export function isLoopback(value) {
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
function hostNameOf(hostHeader) {
|
|
83
|
+
const raw = String(Array.isArray(hostHeader) ? (hostHeader[0] || '') : (hostHeader || '')).trim().toLowerCase()
|
|
84
|
+
if (!raw) return ''
|
|
85
|
+
if (raw === '::1') return '::1'
|
|
86
|
+
if (raw.startsWith('[')) {
|
|
87
|
+
const end = raw.indexOf(']')
|
|
88
|
+
return end > 0 ? raw.slice(0, end + 1) : raw
|
|
89
|
+
}
|
|
90
|
+
return raw.split(':')[0]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function configuredTrustedHosts(targetCtx) {
|
|
94
|
+
const out = new Set()
|
|
95
|
+
for (const part of String(process.env.DSH_TRUSTED_HOSTS || '').split(',')) {
|
|
96
|
+
const host = hostNameOf(part)
|
|
97
|
+
if (host) out.add(host)
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const url = targetCtx && targetCtx.webServer && targetCtx.webServer.url
|
|
101
|
+
if (url) out.add(hostNameOf(new URL(url).host))
|
|
102
|
+
} catch { /* ignore malformed web server url */ }
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
/**
|
|
107
|
+
* @param {import('node:http').IncomingMessage} request
|
|
108
|
+
* @param {any} [targetCtx]
|
|
109
|
+
* @returns {boolean}/**
|
|
83
110
|
* @param {import('node:http').IncomingMessage} request
|
|
84
111
|
* @param {any} [targetCtx]
|
|
85
112
|
* @returns {boolean}
|
|
@@ -103,7 +130,17 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
103
130
|
if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
|
|
104
131
|
return false
|
|
105
132
|
}
|
|
106
|
-
} else if (u.protocol
|
|
133
|
+
} else if (u.protocol === 'dsh-app:') {
|
|
134
|
+
const appHost = u.host.toLowerCase()
|
|
135
|
+
if (appHost && appHost !== 'app' && appHost !== 'localhost') {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
const remoteAddr = request?.socket?.remoteAddress || request?.connection?.remoteAddress
|
|
139
|
+
const hasRemote = Boolean(remoteAddr || request?.socket || request?.connection)
|
|
140
|
+
if (hasRemote && !isLoopback(request)) {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
107
144
|
return false
|
|
108
145
|
}
|
|
109
146
|
} catch {
|
|
@@ -120,7 +157,17 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
120
157
|
if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
|
|
121
158
|
return false
|
|
122
159
|
}
|
|
123
|
-
} else if (u.protocol
|
|
160
|
+
} else if (u.protocol === 'dsh-app:') {
|
|
161
|
+
const appHost = u.host.toLowerCase()
|
|
162
|
+
if (appHost && appHost !== 'app' && appHost !== 'localhost') {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
const remoteAddr = request?.socket?.remoteAddress || request?.connection?.remoteAddress
|
|
166
|
+
const hasRemote = Boolean(remoteAddr || request?.socket || request?.connection)
|
|
167
|
+
if (hasRemote && !isLoopback(request)) {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
124
171
|
return false
|
|
125
172
|
}
|
|
126
173
|
} catch {
|
|
@@ -128,6 +175,20 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
128
175
|
}
|
|
129
176
|
}
|
|
130
177
|
|
|
178
|
+
// 4a. Loopback peers must present a loopback or configured Host.
|
|
179
|
+
// A loopback socket with a foreign Host is a DNS-rebinding attempt.
|
|
180
|
+
const peerAddress = request?.socket?.remoteAddress || request?.connection?.remoteAddress
|
|
181
|
+
if (peerAddress && isLoopback(peerAddress)) {
|
|
182
|
+
const host = hostNameOf(headers.host)
|
|
183
|
+
const loopbackHost = host === ''
|
|
184
|
+
|| host === 'localhost'
|
|
185
|
+
|| host === 'localhost.'
|
|
186
|
+
|| host === '127.0.0.1'
|
|
187
|
+
|| host === '::1'
|
|
188
|
+
|| host === '[::1]'
|
|
189
|
+
if (!loopbackHost && !configuredTrustedHosts(targetCtx).has(host)) return false
|
|
190
|
+
}
|
|
191
|
+
|
|
131
192
|
// 4. Delegate to DSH connection service if available (#374)
|
|
132
193
|
let conn = null
|
|
133
194
|
try {
|
|
@@ -177,7 +238,8 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
177
238
|
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
|
178
239
|
if (u.host.toLowerCase() === host.toLowerCase()) return true
|
|
179
240
|
} else if (u.protocol === 'dsh-app:') {
|
|
180
|
-
|
|
241
|
+
const appHost = u.host.toLowerCase()
|
|
242
|
+
if (!appHost || appHost === 'app' || appHost === 'localhost') return true
|
|
181
243
|
}
|
|
182
244
|
} catch { /* bestEffort */ }
|
|
183
245
|
}
|
|
@@ -187,7 +249,8 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
187
249
|
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
|
188
250
|
if (u.host.toLowerCase() === host.toLowerCase()) return true
|
|
189
251
|
} else if (u.protocol === 'dsh-app:') {
|
|
190
|
-
|
|
252
|
+
const appHost = u.host.toLowerCase()
|
|
253
|
+
if (!appHost || appHost === 'app' || appHost === 'localhost') return true
|
|
191
254
|
}
|
|
192
255
|
} catch { /* bestEffort */ }
|
|
193
256
|
}
|
package/lib/import-auth.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readClaudeCredentials } from './vendors/claude-cli.js'
|
|
|
6
6
|
import { readCopilotCredentials } from './vendors/copilot-cli.js'
|
|
7
7
|
import { normalizeExpiresAt } from './blob.js'
|
|
8
8
|
import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
|
|
11
11
|
// Test seam: a fixture root overrides the real home for the duration of a
|
|
12
12
|
// public call. Undefined (the default) keeps production behaviour.
|
|
@@ -146,7 +146,8 @@ async function discoverLocalCliSessionsInner() {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
const canReadWindowsCredentialManager = Boolean(activeWindowsCredentialReader) || (!activeHome && process.platform === 'win32')
|
|
150
|
+
if (!detected.antigravity && canReadWindowsCredentialManager) {
|
|
150
151
|
const winCred = readWindowsAntigravityCredential()
|
|
151
152
|
const tok = winCred && winCred.token ? winCred.token : winCred
|
|
152
153
|
if (tok && (tok.access_token || tok.accessToken)) {
|
|
@@ -453,10 +454,6 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
453
454
|
raw.installed?.client_secret || raw.web?.client_secret || ''
|
|
454
455
|
let clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
|
|
455
456
|
|
|
456
|
-
if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
|
|
457
|
-
clientSecret = DEFAULT_CLI_CLIENT_SECRET
|
|
458
|
-
}
|
|
459
|
-
|
|
460
457
|
const rawExpiry = tok.expiry ?? tok.expires_at ?? tok.expiresAt ?? tok.expiry_date ?? raw.expiry_date ?? raw.expiry ?? (tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : 0)
|
|
461
458
|
const expiresAt = normalizeExpiresAt(rawExpiry)
|
|
462
459
|
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { plainConfig } from './config-schema.js'
|
|
|
2
2
|
export { plainConfig } from './config-schema.js'
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
4
|
import { bestEffort } from './utils.js'
|
|
5
|
+
import { sweepExpiredPending } from './oauth-pending.js'
|
|
5
6
|
export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
|
|
6
7
|
import { Config } from './config-schema.js'
|
|
7
8
|
import { registerRoutes } from './routes.js'
|
|
@@ -21,8 +22,10 @@ import { proxyFetch } from './proxy.js'
|
|
|
21
22
|
import { HistoryStore, resolveHistoryDir } from './history.js'
|
|
22
23
|
import { createDiagnosticsReport } from './diagnostics.js'
|
|
23
24
|
import { createAccountsView } from './accounts-view.js'
|
|
25
|
+
import { inspectScopes } from './scope-inspector.js'
|
|
24
26
|
import { createAdapterManager } from './adapter-manager.js'
|
|
25
|
-
import { pickAccount } from './rotate.js'
|
|
27
|
+
import { pickAccount, isAuthError } from './rotate.js'
|
|
28
|
+
import { notifySessionExpired } from './alerts.js'
|
|
26
29
|
|
|
27
30
|
export { observeForecast, estimateForecast } from './forecast.js'
|
|
28
31
|
export { formatRelativeReset } from './relative-time.js'
|
|
@@ -31,6 +34,9 @@ export { exportVault, importVault } from './vault.js'
|
|
|
31
34
|
export { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
|
|
32
35
|
export { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
|
|
33
36
|
export { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
|
|
37
|
+
export { encodeQrMatrix, renderAsciiQr, renderSvgQr, renderSvgDataUrl, generateSshTunnelCommand, getHeadlessAuthInfo, handleHeadlessRedirect } from './headless-auth.js'
|
|
38
|
+
export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Headers, pushToS3, pullFromS3, syncVaultPush, syncVaultPull, getVaultSyncStatus, resetVaultSyncStatus } from './vault-sync.js'
|
|
39
|
+
import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
|
|
34
40
|
|
|
35
41
|
export const name = '@goodandready/dsh-subscriptions'
|
|
36
42
|
export const inject = ['llm', 'credentials', 'webServer', 'settings']
|
|
@@ -129,6 +135,7 @@ export function apply(ctx, rawConfig) {
|
|
|
129
135
|
|
|
130
136
|
const settingsApi = {
|
|
131
137
|
get: () => live(),
|
|
138
|
+
revision: () => describeRow()?.revision,
|
|
132
139
|
replace: async (next, expectedRevision) => {
|
|
133
140
|
const parsed = plainConfig(next)
|
|
134
141
|
const prevConfig = cachedConfig
|
|
@@ -380,11 +387,15 @@ export function apply(ctx, rawConfig) {
|
|
|
380
387
|
const pending = new Map()
|
|
381
388
|
|
|
382
389
|
function sweepPending(now) {
|
|
383
|
-
|
|
384
|
-
if (now - row.createdAt > PENDING_TTL_MS) pending.delete(stateKey)
|
|
385
|
-
}
|
|
390
|
+
sweepExpiredPending(pending, now, PENDING_TTL_MS)
|
|
386
391
|
}
|
|
387
392
|
|
|
393
|
+
ctx.effect(() => {
|
|
394
|
+
const id = setInterval(() => sweepPending(Date.now()), 60 * 1000)
|
|
395
|
+
if (id && typeof id.unref === 'function') id.unref()
|
|
396
|
+
return () => clearInterval(id)
|
|
397
|
+
}, 'dsh-subscriptions: pending oauth sweep')
|
|
398
|
+
|
|
388
399
|
async function completeOAuth({ provider, index, code, state }) {
|
|
389
400
|
if (!state) throw new Error('missing oauth state')
|
|
390
401
|
sweepPending(Date.now())
|
|
@@ -401,14 +412,26 @@ export function apply(ctx, rawConfig) {
|
|
|
401
412
|
challenge: row.challenge,
|
|
402
413
|
state: row.state,
|
|
403
414
|
}, code, fetchForRef(ref) || fetch)
|
|
415
|
+
const inspection = inspectScopes(actualProvider, blob.scopes)
|
|
416
|
+
blob.scopes = inspection.scopes
|
|
417
|
+
blob.scopeWarning = inspection.scopeWarning || ""
|
|
404
418
|
const slots = normalizeSlots(live().slots)
|
|
405
419
|
const slot = slots.find((s) => s.ref === ref)
|
|
406
420
|
if (slot && slot.label) blob.label = slot.label
|
|
407
|
-
|
|
421
|
+
let existing = {}
|
|
422
|
+
try { existing = await store.loadBlob(ref) } catch { /* no existing blob on fresh auth */ }
|
|
423
|
+
const merged = { ...existing, ...blob }
|
|
424
|
+
await store.saveBlob(ref, merged)
|
|
425
|
+
store.recordSuccess(ref)
|
|
426
|
+
store.clearRefreshFailure(ref)
|
|
427
|
+
store.clearCooldown(ref)
|
|
408
428
|
pending.delete(row.state)
|
|
409
429
|
await syncAdapter()
|
|
410
430
|
refreshModels().catch(() => {})
|
|
411
|
-
|
|
431
|
+
if (live().vaultSyncEnabled && live().vaultSyncAutoPush) {
|
|
432
|
+
syncVaultPush({ live: live(), store }).catch(() => {})
|
|
433
|
+
}
|
|
434
|
+
return { ref, label: pmL(merged.label || merged.email) || displayName(actualProvider) }
|
|
412
435
|
}
|
|
413
436
|
|
|
414
437
|
// Privacy masking
|
|
@@ -449,7 +472,7 @@ export function apply(ctx, rawConfig) {
|
|
|
449
472
|
}
|
|
450
473
|
|
|
451
474
|
const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats })
|
|
452
|
-
const accountsView = () => createAccountsView({ live, store, fetch, pmL })
|
|
475
|
+
const accountsView = () => createAccountsView({ live, store, fetch, pmL, pmE, history })
|
|
453
476
|
|
|
454
477
|
// Ponytail: background refresh ahead of expiry, single timer
|
|
455
478
|
ctx.effect(() => {
|
|
@@ -473,15 +496,51 @@ export function apply(ctx, rawConfig) {
|
|
|
473
496
|
if (!raw) continue
|
|
474
497
|
const blob = await store.loadBlob(ref).catch(() => null)
|
|
475
498
|
if (!blob || !blob.refreshToken) continue
|
|
476
|
-
|
|
499
|
+
let fresh
|
|
500
|
+
try {
|
|
501
|
+
fresh = await store.ensureFresh(slot.provider, blob, ref)
|
|
502
|
+
} catch (refreshErr) {
|
|
503
|
+
if (isAuthError(refreshErr)) {
|
|
504
|
+
store.recordBroken(ref)
|
|
505
|
+
store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
|
|
506
|
+
try {
|
|
507
|
+
notifySessionExpired({
|
|
508
|
+
ref,
|
|
509
|
+
provider: slot.provider,
|
|
510
|
+
message: refreshErr && refreshErr.message,
|
|
511
|
+
webhookUrl: cfg.webhookUrl,
|
|
512
|
+
fetchImpl: fetch,
|
|
513
|
+
nowMs: Date.now(),
|
|
514
|
+
})
|
|
515
|
+
} catch { /* alert best-effort */ }
|
|
516
|
+
}
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
477
519
|
if (!fresh) continue
|
|
478
520
|
const vendor = getVendor(slot.provider)
|
|
479
521
|
if (typeof vendor.check !== 'function') continue
|
|
480
522
|
const cfg2 = vendorConfig(slot.provider, live())
|
|
481
523
|
const probeFetch = async (u, i) => ((typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch)(u, i)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
524
|
+
try {
|
|
525
|
+
await vendor.check(fresh, cfg2, probeFetch)
|
|
526
|
+
if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, null)
|
|
527
|
+
} catch (checkErr) {
|
|
528
|
+
if (isAuthError(checkErr)) {
|
|
529
|
+
store.recordBroken(ref)
|
|
530
|
+
store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
|
|
531
|
+
try {
|
|
532
|
+
notifySessionExpired({
|
|
533
|
+
ref,
|
|
534
|
+
provider: slot.provider,
|
|
535
|
+
message: checkErr && checkErr.message,
|
|
536
|
+
webhookUrl: cfg.webhookUrl,
|
|
537
|
+
fetchImpl: fetch,
|
|
538
|
+
nowMs: Date.now(),
|
|
539
|
+
})
|
|
540
|
+
} catch { /* alert best-effort */ }
|
|
541
|
+
}
|
|
542
|
+
if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(checkErr && checkErr.message || checkErr).slice(0, 200))
|
|
543
|
+
}
|
|
485
544
|
} catch { /* best-effort */ }
|
|
486
545
|
}
|
|
487
546
|
}
|
|
@@ -499,6 +558,35 @@ export function apply(ctx, rawConfig) {
|
|
|
499
558
|
return () => clearInterval(timer)
|
|
500
559
|
}, 'dsh-subscriptions: probe loop')
|
|
501
560
|
|
|
561
|
+
// #562: Background automated remote vault sync loop
|
|
562
|
+
ctx.effect(() => {
|
|
563
|
+
const checkSync = async () => {
|
|
564
|
+
const cfg = live()
|
|
565
|
+
if (!cfg.vaultSyncEnabled || !cfg.vaultSyncIntervalHours || cfg.vaultSyncIntervalHours <= 0) return
|
|
566
|
+
const status = getVaultSyncStatus()
|
|
567
|
+
const now = Date.now()
|
|
568
|
+
const intervalMs = cfg.vaultSyncIntervalHours * 3600 * 1000
|
|
569
|
+
const lastPull = status.lastPullAt ? new Date(status.lastPullAt).getTime() : 0
|
|
570
|
+
if (now - lastPull >= intervalMs) {
|
|
571
|
+
try {
|
|
572
|
+
await syncVaultPull({
|
|
573
|
+
live: cfg,
|
|
574
|
+
store,
|
|
575
|
+
settingsApi: getSettingsApi(),
|
|
576
|
+
syncAdapter,
|
|
577
|
+
refreshModels,
|
|
578
|
+
})
|
|
579
|
+
} catch { /* status captures error */ }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const intervalId = setInterval(() => { checkSync().catch(() => {}) }, 60 * 60 * 1000)
|
|
583
|
+
const initialTimer = setTimeout(() => { checkSync().catch(() => {}) }, 30 * 1000)
|
|
584
|
+
return () => {
|
|
585
|
+
clearInterval(intervalId)
|
|
586
|
+
clearTimeout(initialTimer)
|
|
587
|
+
}
|
|
588
|
+
}, 'dsh-subscriptions: vault-sync-loop')
|
|
589
|
+
|
|
502
590
|
registerRoutes(ctx, {
|
|
503
591
|
NS,
|
|
504
592
|
live,
|
package/lib/jwt.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
function decodeJwtPayload(token) {
|
|
2
|
-
const raw = String(token ||
|
|
3
|
-
const parts = raw.split(
|
|
1
|
+
export function decodeJwtPayload(token) {
|
|
2
|
+
const raw = String(token || "")
|
|
3
|
+
const parts = raw.split(".")
|
|
4
4
|
if (parts.length < 2) return null
|
|
5
5
|
try {
|
|
6
|
-
const json = Buffer.from(parts[1],
|
|
6
|
+
const json = Buffer.from(parts[1], "base64url").toString("utf8")
|
|
7
7
|
return JSON.parse(json)
|
|
8
8
|
} catch {
|
|
9
9
|
return null
|
|
@@ -12,42 +12,84 @@ function decodeJwtPayload(token) {
|
|
|
12
12
|
|
|
13
13
|
export function chatgptAccountId(token) {
|
|
14
14
|
const payload = decodeJwtPayload(token)
|
|
15
|
-
if (!payload || typeof payload !==
|
|
15
|
+
if (!payload || typeof payload !== "object") return ""
|
|
16
16
|
if (payload.chatgpt_account_id) return String(payload.chatgpt_account_id)
|
|
17
|
-
const nested = payload[
|
|
17
|
+
const nested = payload["https://api.openai.com/auth"]
|
|
18
18
|
if (nested && nested.chatgpt_account_id) return String(nested.chatgpt_account_id)
|
|
19
|
+
if (nested && nested.user_id) return String(nested.user_id)
|
|
19
20
|
const orgs = payload.organizations
|
|
20
21
|
if (Array.isArray(orgs) && orgs[0] && orgs[0].id) return String(orgs[0].id)
|
|
21
|
-
return
|
|
22
|
+
if (payload.sub) return String(payload.sub)
|
|
23
|
+
return ""
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function emailFromToken(token) {
|
|
25
27
|
const payload = decodeJwtPayload(token)
|
|
26
|
-
if (!payload) return
|
|
27
|
-
|
|
28
|
+
if (!payload || typeof payload !== "object") return ""
|
|
29
|
+
if (payload.email) return String(payload.email)
|
|
30
|
+
const openaiProf = payload["https://api.openai.com/profile"]
|
|
31
|
+
if (openaiProf && openaiProf.email) return String(openaiProf.email)
|
|
32
|
+
if (payload.preferred_username) return String(payload.preferred_username)
|
|
33
|
+
if (payload.upn) return String(payload.upn)
|
|
34
|
+
return ""
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function planFromToken(token) {
|
|
38
|
+
const payload = decodeJwtPayload(token)
|
|
39
|
+
if (!payload || typeof payload !== "object") return ""
|
|
40
|
+
const nested = payload["https://api.openai.com/auth"]
|
|
41
|
+
if (nested && nested.chatgpt_plan_type) return String(nested.chatgpt_plan_type)
|
|
42
|
+
const anthropic = payload["https://api.anthropic.com"]
|
|
43
|
+
if (anthropic && anthropic.plan) return String(anthropic.plan)
|
|
44
|
+
if (payload.plan_type) return String(payload.plan_type)
|
|
45
|
+
if (payload.plan) return typeof payload.plan === "string" ? payload.plan : (payload.plan.name || "")
|
|
46
|
+
if (payload.subscription && payload.subscription.tier) return String(payload.subscription.tier)
|
|
47
|
+
if (payload.subscription_tier) return String(payload.subscription_tier)
|
|
48
|
+
if (payload.paidTierName) return String(payload.paidTierName)
|
|
49
|
+
return ""
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function avatarFromToken(token) {
|
|
53
|
+
const payload = decodeJwtPayload(token)
|
|
54
|
+
if (!payload || typeof payload !== "object") return ""
|
|
55
|
+
if (payload.picture && typeof payload.picture === "string") return payload.picture
|
|
56
|
+
if (payload.avatar_url && typeof payload.avatar_url === "string") return payload.avatar_url
|
|
57
|
+
if (payload.avatar && typeof payload.avatar === "string") return payload.avatar
|
|
58
|
+
const openaiProf = payload["https://api.openai.com/profile"]
|
|
59
|
+
if (openaiProf && openaiProf.picture && typeof openaiProf.picture === "string") return openaiProf.picture
|
|
60
|
+
return ""
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function scopesFromToken(token) {
|
|
64
|
+
const payload = decodeJwtPayload(token)
|
|
65
|
+
if (!payload || typeof payload !== "object") return []
|
|
66
|
+
const raw = payload.scope || payload.scp || ""
|
|
67
|
+
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
|
|
68
|
+
if (typeof raw === "string") return raw.split(/[\s,]+/).filter(Boolean)
|
|
69
|
+
return []
|
|
28
70
|
}
|
|
29
71
|
|
|
30
72
|
export function isGoogleClientId(str) {
|
|
31
|
-
if (typeof str !==
|
|
73
|
+
if (typeof str !== "string") return false
|
|
32
74
|
const trimmed = str.trim()
|
|
33
75
|
return /^[0-9]+-[a-zA-Z0-9_-]+\.apps\.googleusercontent\.com$/.test(trimmed)
|
|
34
76
|
}
|
|
35
77
|
|
|
36
78
|
export function extractGoogleClientIdFromJwt(token) {
|
|
37
79
|
const payload = decodeJwtPayload(token)
|
|
38
|
-
if (!payload || typeof payload !==
|
|
80
|
+
if (!payload || typeof payload !== "object") return ""
|
|
39
81
|
|
|
40
|
-
const azp = typeof payload.azp ===
|
|
82
|
+
const azp = typeof payload.azp === "string" ? payload.azp.trim() : ""
|
|
41
83
|
const rawAud = payload.aud
|
|
42
84
|
|
|
43
85
|
const audiences = Array.isArray(rawAud)
|
|
44
|
-
? rawAud.filter(a => typeof a ===
|
|
45
|
-
: (typeof rawAud ===
|
|
86
|
+
? rawAud.filter(a => typeof a === "string").map(a => a.trim())
|
|
87
|
+
: (typeof rawAud === "string" && rawAud.trim() ? [rawAud.trim()] : [])
|
|
46
88
|
|
|
47
89
|
if (azp) {
|
|
48
|
-
if (!isGoogleClientId(azp)) return
|
|
90
|
+
if (!isGoogleClientId(azp)) return ""
|
|
49
91
|
if (audiences.length > 0 && !audiences.includes(azp)) {
|
|
50
|
-
return
|
|
92
|
+
return ""
|
|
51
93
|
}
|
|
52
94
|
return azp
|
|
53
95
|
}
|
|
@@ -57,5 +99,30 @@ export function extractGoogleClientIdFromJwt(token) {
|
|
|
57
99
|
return validAuds[0]
|
|
58
100
|
}
|
|
59
101
|
|
|
60
|
-
return
|
|
102
|
+
return ""
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function organizationsFromToken(token) {
|
|
106
|
+
const payload = decodeJwtPayload(token)
|
|
107
|
+
if (!payload || typeof payload !== "object") return []
|
|
108
|
+
const raw = payload.organizations ||
|
|
109
|
+
(payload["https://api.openai.com/auth"] && payload["https://api.openai.com/auth"].organizations) ||
|
|
110
|
+
payload.orgs ||
|
|
111
|
+
(payload["https://api.anthropic.com/auth"] && payload["https://api.anthropic.com/auth"].organizations) ||
|
|
112
|
+
[]
|
|
113
|
+
if (!Array.isArray(raw)) return []
|
|
114
|
+
const out = []
|
|
115
|
+
for (const item of raw) {
|
|
116
|
+
if (typeof item === "string" && item.trim()) {
|
|
117
|
+
out.push({ id: item.trim(), name: item.trim() })
|
|
118
|
+
} else if (item && typeof item === "object") {
|
|
119
|
+
const id = String(item.id || item.org_id || item.uuid || "").trim()
|
|
120
|
+
const name = String(item.title || item.name || item.org_name || id).trim()
|
|
121
|
+
const role = item.role ? String(item.role).trim() : undefined
|
|
122
|
+
if (id) {
|
|
123
|
+
out.push({ id, name: name || id, ...(role ? { role } : {}) })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
61
128
|
}
|
package/lib/loopback.js
CHANGED
|
@@ -30,8 +30,9 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
|
|
|
30
30
|
const promise = new Promise((resolve, reject) => {
|
|
31
31
|
const server = http.createServer((req, res) => {
|
|
32
32
|
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
33
|
-
// The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
|
|
34
|
-
|
|
33
|
+
// The provider may redirect to a suffixed path (e.g. /auth/callback/extra) (#545)
|
|
34
|
+
const matchesPath = url.pathname === path || (path.endsWith("/") ? url.pathname.startsWith(path) : url.pathname.startsWith(path + "/"));
|
|
35
|
+
if (!matchesPath) {
|
|
35
36
|
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
36
37
|
res.end(ERR_HTML)
|
|
37
38
|
return
|
package/lib/mask.js
CHANGED
|
@@ -20,6 +20,22 @@ export function maskText(value) {
|
|
|
20
20
|
return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
const SECRET_PATTERNS = [
|
|
24
|
+
[/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]'],
|
|
25
|
+
[/\b(?:sk|ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs])[-_][A-Za-z0-9._-]{8,}/g, '[REDACTED]'],
|
|
26
|
+
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]'],
|
|
27
|
+
[/\b(access_token|refresh_token|token|key|secret|password)=([^&\s]+)/gi, '$1=[REDACTED]'],
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
// #578: redact credentials as well as emails from free-text diagnostics.
|
|
31
|
+
export function maskSecrets(value) {
|
|
32
|
+
let text = maskText(value)
|
|
33
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
34
|
+
text = text.replace(pattern, replacement)
|
|
35
|
+
}
|
|
36
|
+
return text
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
export function cleanErrorMessage(raw) {
|
|
24
40
|
if (!raw) return ''
|
|
25
41
|
let text = typeof raw === 'string' ? raw : (raw.message || String(raw))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// #580: shared expiry sweep for in-memory OAuth pending rows.
|
|
2
|
+
export function sweepExpiredPending(pending, nowMs, ttlMs) {
|
|
3
|
+
let removed = 0
|
|
4
|
+
for (const [stateKey, row] of pending) {
|
|
5
|
+
const createdAt = Number(row && row.createdAt)
|
|
6
|
+
if (!Number.isFinite(createdAt) || Number(nowMs) - createdAt > Number(ttlMs)) {
|
|
7
|
+
pending.delete(stateKey)
|
|
8
|
+
removed += 1
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return removed
|
|
12
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Subscription Value Delivered & API Cost Equivalent Counter (#561)
|
|
2
|
+
// Translates raw prompt, completion and cache tokens into equivalent official API pricing,
|
|
3
|
+
// comparing delivered value against monthly subscription fee ($20/mo standard).
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SUBSCRIPTION_MONTHLY_COST = 20.0
|
|
6
|
+
|
|
7
|
+
export const TOKEN_RATES = {
|
|
8
|
+
'claude-3-7-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
|
|
9
|
+
'claude-3-5-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
|
|
10
|
+
'claude-3-5-haiku': { prompt: 0.8, completion: 4.0, cacheRead: 0.08 },
|
|
11
|
+
'claude-3-opus': { prompt: 15.0, completion: 75.0, cacheRead: 1.5 },
|
|
12
|
+
'gpt-4o': { prompt: 2.5, completion: 10.0, cacheRead: 1.25 },
|
|
13
|
+
'gpt-4o-mini': { prompt: 0.15, completion: 0.6, cacheRead: 0.075 },
|
|
14
|
+
'o1': { prompt: 15.0, completion: 60.0, cacheRead: 7.5 },
|
|
15
|
+
'o3-mini': { prompt: 1.1, completion: 4.4, cacheRead: 0.55 },
|
|
16
|
+
'grok-2': { prompt: 2.0, completion: 10.0, cacheRead: 0.2 },
|
|
17
|
+
'sonar-reasoning': { prompt: 5.0, completion: 25.0, cacheRead: 0.5 },
|
|
18
|
+
'sonar-pro': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
|
|
19
|
+
'sonar': { prompt: 1.0, completion: 5.0, cacheRead: 0.1 },
|
|
20
|
+
'gemini-1.5-pro': { prompt: 1.25, completion: 5.0, cacheRead: 0.3 },
|
|
21
|
+
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4, cacheRead: 0.025 },
|
|
22
|
+
'default': { prompt: 2.5, completion: 10.0, cacheRead: 0.5 },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves standard token rates per 1M tokens for a given model identifier.
|
|
27
|
+
* @param {string} [modelId]
|
|
28
|
+
* @returns {{ prompt: number, completion: number, cacheRead: number }}
|
|
29
|
+
*/
|
|
30
|
+
export function lookupTokenRate(modelId = '') {
|
|
31
|
+
const m = String(modelId || '').toLowerCase()
|
|
32
|
+
for (const [key, rates] of Object.entries(TOKEN_RATES)) {
|
|
33
|
+
if (key !== 'default' && m.includes(key)) {
|
|
34
|
+
return rates
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return TOKEN_RATES.default
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calculates dollar cost equivalent for the given token quantities.
|
|
42
|
+
* @param {number} promptTokens
|
|
43
|
+
* @param {number} completionTokens
|
|
44
|
+
* @param {number} [cacheReadTokens]
|
|
45
|
+
* @param {string} [modelId]
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
export function calculateEquivalentCost(promptTokens = 0, completionTokens = 0, cacheReadTokens = 0, modelId = 'default') {
|
|
49
|
+
const rates = lookupTokenRate(modelId)
|
|
50
|
+
const p = (Number(promptTokens) || 0) * (rates.prompt / 1_000_000)
|
|
51
|
+
const c = (Number(completionTokens) || 0) * (rates.completion / 1_000_000)
|
|
52
|
+
const cr = (Number(cacheReadTokens) || 0) * (rates.cacheRead / 1_000_000)
|
|
53
|
+
return Math.round((p + c + cr) * 100) / 100
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Computes value delivered metrics and ROI ratio for an account.
|
|
58
|
+
* @param {object} params
|
|
59
|
+
* @param {number} [params.promptTokens]
|
|
60
|
+
* @param {number} [params.completionTokens]
|
|
61
|
+
* @param {number} [params.cacheReadTokens]
|
|
62
|
+
* @param {string} [params.modelId]
|
|
63
|
+
* @param {number} [params.subscriptionCost]
|
|
64
|
+
* @returns {object}
|
|
65
|
+
*/
|
|
66
|
+
export function calculateValueDelivered({
|
|
67
|
+
promptTokens = 0,
|
|
68
|
+
completionTokens = 0,
|
|
69
|
+
cacheReadTokens = 0,
|
|
70
|
+
modelId = 'default',
|
|
71
|
+
subscriptionCost = DEFAULT_SUBSCRIPTION_MONTHLY_COST,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const equivalentCost = calculateEquivalentCost(promptTokens, completionTokens, cacheReadTokens, modelId)
|
|
74
|
+
const subCost = Number(subscriptionCost) > 0 ? Number(subscriptionCost) : DEFAULT_SUBSCRIPTION_MONTHLY_COST
|
|
75
|
+
const roiMultiplier = Math.round((equivalentCost / subCost) * 10) / 10
|
|
76
|
+
const roiLabel = `${roiMultiplier}x`
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
equivalentCost,
|
|
80
|
+
formattedCost: `$${equivalentCost.toFixed(2)}`,
|
|
81
|
+
subscriptionCost: subCost,
|
|
82
|
+
formattedSubscriptionCost: `$${subCost.toFixed(2)}`,
|
|
83
|
+
roiMultiplier,
|
|
84
|
+
roiLabel,
|
|
85
|
+
promptTokens: Number(promptTokens) || 0,
|
|
86
|
+
completionTokens: Number(completionTokens) || 0,
|
|
87
|
+
cacheReadTokens: Number(cacheReadTokens) || 0,
|
|
88
|
+
}
|
|
89
|
+
}
|