@goodandready/dsh-clinebot 0.5.33 → 0.6.1

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.
@@ -1,187 +1,187 @@
1
- /**
2
- * Local Proxy Authentication Token (Issues #145, #247)
3
- *
4
- * Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
5
- * LAN clients or cross-site browser requests (CSRF).
6
- * Token is stored strictly in-memory and isolated from process.env and child processes (#247).
7
- */
8
-
9
- import crypto from 'node:crypto'
10
- import { createRequire } from 'node:module'
11
- import { writeJson, isLoopbackAddress, getConnection } from './http.js'
12
- import { resolveAccountPool } from './account-pool.js'
13
-
14
- export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
15
-
16
- let cachedToken = null
17
-
18
- // At module startup, scrub any token from process.env if present (#247)
19
- if (process.env[LOCAL_PROXY_KEY_ENV]) {
20
- cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
21
- delete process.env[LOCAL_PROXY_KEY_ENV]
22
- }
23
-
24
- const require = createRequire(import.meta.url)
25
- let packageVersion = '0.5.33'
26
- try {
27
- packageVersion = require('../package.json').version || '0.5.33'
28
- } catch (err) {
29
- /* fallback to default version if manifest is unreadable */
30
- }
31
-
32
- export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
33
-
34
- /**
35
- * Get or generate the local proxy bearer token.
36
- * Token is stored strictly in module-private memory and never exported to process.env (#247).
37
- *
38
- * @returns {string}
39
- */
40
- export function getLocalProxyToken() {
41
- if (cachedToken) return cachedToken
42
- if (process.env[LOCAL_PROXY_KEY_ENV]) {
43
- cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
44
- delete process.env[LOCAL_PROXY_KEY_ENV]
45
- return cachedToken
46
- }
47
- cachedToken = crypto.randomBytes(24).toString('hex')
48
- return cachedToken
49
- }
50
-
51
- /**
52
- * Manually set local proxy token (for testing).
53
- * Never leaks token to process.env (#247).
54
- *
55
- * @param {string|null} token
56
- */
57
- export function setLocalProxyToken(token) {
58
- cachedToken = token
59
- delete process.env[LOCAL_PROXY_KEY_ENV]
60
- }
61
-
62
- /**
63
- * Reject cross-site and browser requests to proxy endpoints.
64
- */
65
- export function hasDisallowedBrowserOrigin(req) {
66
- const secFetchSite = req.headers?.['sec-fetch-site']
67
- return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
68
- }
69
-
70
- /**
71
- * Verify whether incoming HTTP request is authorized to access proxy endpoints.
72
- */
73
- export function isAuthorizedProxyRequest(req, accountPool = []) {
74
- const auth = req.headers?.['authorization'] || ''
75
- const match = auth.match(/^Bearer\s+(.+)$/i)
76
- if (!match) return false
77
- const token = match[1].trim()
78
- if (!token) return false
79
- const localToken = getLocalProxyToken()
80
- if (localToken && token === localToken) return true
81
- return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
82
- }
83
-
84
- /**
85
- * Unified gate for proxy endpoints (/v1/chat/completions, /v1/models) (#247).
86
- * Verifies loopback address, browser origin, DSH connection rejection, and bearer token.
87
- */
88
- export async function assertAuthorizedProxyRequest(ctx, req, res, live) {
89
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
90
- writeJson(res, 403, { ok: false, error: 'Loopback access only' })
91
- return { ok: false }
92
- }
93
-
94
- if (hasDisallowedBrowserOrigin(req)) {
95
- writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
96
- return { ok: false }
97
- }
98
-
99
- const conn = getConnection(ctx) || req?.connectionService
100
- if (conn && typeof conn.requestRejection === 'function') {
101
- let rejection
102
- try {
103
- rejection = conn.requestRejection(req)
104
- } catch {
105
- rejection = 403
106
- }
107
- if (rejection !== undefined) {
108
- const status = typeof rejection === 'number' ? rejection : 403
109
- writeJson(res, status, {
110
- ok: false,
111
- error: status === 401 ? 'Authentication required' : 'Forbidden',
112
- })
113
- return { ok: false }
114
- }
115
- }
116
-
117
- const pool = await resolveAccountPool(ctx, typeof live === 'function' ? live() : live)
118
- if (!isAuthorizedProxyRequest(req, pool)) {
119
- writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
120
- return { ok: false }
121
- }
122
-
123
- return { ok: true, pool }
124
- }
125
-
126
- /**
127
- * Write appropriate terminal JSON error response when retries are exhausted.
128
- */
129
- export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter }) {
130
- if (hadTimeout) {
131
- const code = lastError?.code || (String(lastError?.message).toLowerCase().includes('body read') ? 'body_read_timeout' : 'upstream_timeout')
132
- return writeJson(res, 504, {
133
- error: {
134
- message: 'Gateway Timeout: upstream provider timed out across all available accounts',
135
- type: 'gateway_timeout',
136
- code,
137
- details: lastError?.message || null,
138
- },
139
- })
140
- }
141
-
142
- if (is429Exhausted) {
143
- if (lastRetryAfter) {
144
- res.setHeader('Retry-After', lastRetryAfter)
145
- }
146
- return writeJson(res, 429, {
147
- error: {
148
- message: 'All configured accounts in pool exceeded rate limits or quota',
149
- type: 'rate_limit_exceeded',
150
- code: 'rate_limit_exceeded',
151
- details: lastError?.message || null,
152
- },
153
- })
154
- }
155
-
156
- const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
157
- ? lastError.status
158
- : 502
159
- const code = lastError?.code || 'upstream_unavailable'
160
- return writeJson(res, status, {
161
- error: {
162
- message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
163
- type: 'bad_gateway',
164
- code,
165
- details: lastError?.message || null,
166
- },
167
- })
168
- }
169
-
170
- /**
171
- * Safely extract standard and reasoning token counts from usage object.
172
- */
173
- export function extractUsageMetrics(usage) {
174
- if (!usage || typeof usage !== 'object') {
175
- return { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, totalTokens: 0 }
176
- }
177
- const promptTokens = Number(usage.prompt_tokens) || 0
178
- const completionTokens = Number(usage.completion_tokens) || 0
179
- const reasoningTokens = Number(
180
- usage.completion_tokens_details?.reasoning_tokens ||
181
- usage.reasoning_tokens ||
182
- usage.prompt_tokens_details?.reasoning_tokens ||
183
- 0
184
- )
185
- const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
186
- return { promptTokens, completionTokens, reasoningTokens, totalTokens }
1
+ /**
2
+ * Local Proxy Authentication Token (Issues #145, #247)
3
+ *
4
+ * Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
5
+ * LAN clients or cross-site browser requests (CSRF).
6
+ * Token is stored strictly in-memory and isolated from process.env and child processes (#247).
7
+ */
8
+
9
+ import crypto from 'node:crypto'
10
+ import { createRequire } from 'node:module'
11
+ import { writeJson, isLoopbackAddress, getConnection } from './http.js'
12
+ import { resolveAccountPool } from './account-pool.js'
13
+
14
+ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
15
+
16
+ let cachedToken = null
17
+
18
+ // At module startup, scrub any token from process.env if present (#247)
19
+ if (process.env[LOCAL_PROXY_KEY_ENV]) {
20
+ cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
21
+ delete process.env[LOCAL_PROXY_KEY_ENV]
22
+ }
23
+
24
+ const require = createRequire(import.meta.url)
25
+ let packageVersion = '0.6.1'
26
+ try {
27
+ packageVersion = require('../package.json').version || '0.6.1'
28
+ } catch (err) {
29
+ /* fallback to default version if manifest is unreadable */
30
+ }
31
+
32
+ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
33
+
34
+ /**
35
+ * Get or generate the local proxy bearer token.
36
+ * Token is stored strictly in module-private memory and never exported to process.env (#247).
37
+ *
38
+ * @returns {string}
39
+ */
40
+ export function getLocalProxyToken() {
41
+ if (cachedToken) return cachedToken
42
+ if (process.env[LOCAL_PROXY_KEY_ENV]) {
43
+ cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
44
+ delete process.env[LOCAL_PROXY_KEY_ENV]
45
+ return cachedToken
46
+ }
47
+ cachedToken = crypto.randomBytes(24).toString('hex')
48
+ return cachedToken
49
+ }
50
+
51
+ /**
52
+ * Manually set local proxy token (for testing).
53
+ * Never leaks token to process.env (#247).
54
+ *
55
+ * @param {string|null} token
56
+ */
57
+ export function setLocalProxyToken(token) {
58
+ cachedToken = token
59
+ delete process.env[LOCAL_PROXY_KEY_ENV]
60
+ }
61
+
62
+ /**
63
+ * Reject cross-site and browser requests to proxy endpoints.
64
+ */
65
+ export function hasDisallowedBrowserOrigin(req) {
66
+ const secFetchSite = req.headers?.['sec-fetch-site']
67
+ return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
68
+ }
69
+
70
+ /**
71
+ * Verify whether incoming HTTP request is authorized to access proxy endpoints.
72
+ */
73
+ export function isAuthorizedProxyRequest(req, accountPool = []) {
74
+ const auth = req.headers?.['authorization'] || ''
75
+ const match = auth.match(/^Bearer\s+(.+)$/i)
76
+ if (!match) return false
77
+ const token = match[1].trim()
78
+ if (!token) return false
79
+ const localToken = getLocalProxyToken()
80
+ if (localToken && token === localToken) return true
81
+ return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
82
+ }
83
+
84
+ /**
85
+ * Unified gate for proxy endpoints (/v1/chat/completions, /v1/models) (#247).
86
+ * Verifies loopback address, browser origin, DSH connection rejection, and bearer token.
87
+ */
88
+ export async function assertAuthorizedProxyRequest(ctx, req, res, live) {
89
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
90
+ writeJson(res, 403, { ok: false, error: 'Loopback access only' })
91
+ return { ok: false }
92
+ }
93
+
94
+ if (hasDisallowedBrowserOrigin(req)) {
95
+ writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
96
+ return { ok: false }
97
+ }
98
+
99
+ const conn = getConnection(ctx) || req?.connectionService
100
+ if (conn && typeof conn.requestRejection === 'function') {
101
+ let rejection
102
+ try {
103
+ rejection = conn.requestRejection(req)
104
+ } catch {
105
+ rejection = 403
106
+ }
107
+ if (rejection !== undefined) {
108
+ const status = typeof rejection === 'number' ? rejection : 403
109
+ writeJson(res, status, {
110
+ ok: false,
111
+ error: status === 401 ? 'Authentication required' : 'Forbidden',
112
+ })
113
+ return { ok: false }
114
+ }
115
+ }
116
+
117
+ const pool = await resolveAccountPool(ctx, typeof live === 'function' ? live() : live)
118
+ if (!isAuthorizedProxyRequest(req, pool)) {
119
+ writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
120
+ return { ok: false }
121
+ }
122
+
123
+ return { ok: true, pool }
124
+ }
125
+
126
+ /**
127
+ * Write appropriate terminal JSON error response when retries are exhausted.
128
+ */
129
+ export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter }) {
130
+ if (hadTimeout) {
131
+ const code = lastError?.code || (String(lastError?.message).toLowerCase().includes('body read') ? 'body_read_timeout' : 'upstream_timeout')
132
+ return writeJson(res, 504, {
133
+ error: {
134
+ message: 'Gateway Timeout: upstream provider timed out across all available accounts',
135
+ type: 'gateway_timeout',
136
+ code,
137
+ details: lastError?.message || null,
138
+ },
139
+ })
140
+ }
141
+
142
+ if (is429Exhausted) {
143
+ if (lastRetryAfter) {
144
+ res.setHeader('Retry-After', lastRetryAfter)
145
+ }
146
+ return writeJson(res, 429, {
147
+ error: {
148
+ message: 'All configured accounts in pool exceeded rate limits or quota',
149
+ type: 'rate_limit_exceeded',
150
+ code: 'rate_limit_exceeded',
151
+ details: lastError?.message || null,
152
+ },
153
+ })
154
+ }
155
+
156
+ const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
157
+ ? lastError.status
158
+ : 502
159
+ const code = lastError?.code || 'upstream_unavailable'
160
+ return writeJson(res, status, {
161
+ error: {
162
+ message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
163
+ type: 'bad_gateway',
164
+ code,
165
+ details: lastError?.message || null,
166
+ },
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Safely extract standard and reasoning token counts from usage object.
172
+ */
173
+ export function extractUsageMetrics(usage) {
174
+ if (!usage || typeof usage !== 'object') {
175
+ return { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, totalTokens: 0 }
176
+ }
177
+ const promptTokens = Number(usage.prompt_tokens) || 0
178
+ const completionTokens = Number(usage.completion_tokens) || 0
179
+ const reasoningTokens = Number(
180
+ usage.completion_tokens_details?.reasoning_tokens ||
181
+ usage.reasoning_tokens ||
182
+ usage.prompt_tokens_details?.reasoning_tokens ||
183
+ 0
184
+ )
185
+ const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
186
+ return { promptTokens, completionTokens, reasoningTokens, totalTokens }
187
187
  }