dsh-all-usage 1.1.2 → 1.1.3

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/http.js ADDED
@@ -0,0 +1,305 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+
3
+ // local and require a browser-originated capability for state-changing reads/writes.
4
+ function requestHeader(req, name) {
5
+ const headers = req && req.headers
6
+ if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
7
+ const value = headers[name.toLowerCase()]
8
+ if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
9
+ return typeof value === 'string' ? value : undefined
10
+ }
11
+
12
+ function isLoopbackIpv4(address) {
13
+ const parts = address.split('.')
14
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
15
+ }
16
+
17
+ function ipv6Words(address) {
18
+ let value = address.toLowerCase()
19
+ if (value.includes('.')) {
20
+ const separator = value.lastIndexOf(':')
21
+ if (separator < 0) return null
22
+ const ipv4 = value.slice(separator + 1)
23
+ if (!isLoopbackIpv4(ipv4) && !ipv4.split('.').every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null
24
+ const parts = ipv4.split('.').map(Number)
25
+ value = value.slice(0, separator + 1) + (((parts[0] << 8) | parts[1]).toString(16)) + ':' + (((parts[2] << 8) | parts[3]).toString(16))
26
+ }
27
+ const halves = value.split('::')
28
+ if (halves.length > 2) return null
29
+ const left = halves[0] === '' ? [] : halves[0].split(':')
30
+ const right = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : []
31
+ if (halves.length === 1 && left.length !== 8) return null
32
+ const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0
33
+ if (zeroCount < (halves.length === 2 ? 1 : 0)) return null
34
+ const groups = left.concat(Array.from({ length: zeroCount }, () => '0'), right)
35
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null
36
+ return groups.map((group) => Number.parseInt(group, 16))
37
+ }
38
+
39
+ function isLoopbackAddress(address) {
40
+ if (typeof address !== 'string') return false
41
+ const value = address.trim().toLowerCase()
42
+ if (isLoopbackIpv4(value)) return true
43
+ const words = ipv6Words(value)
44
+ if (words === null) return false
45
+ if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return true
46
+ if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
47
+ const ipv4 = [(words[6] >> 8) & 255, words[6] & 255, (words[7] >> 8) & 255, words[7] & 255].join('.')
48
+ return isLoopbackIpv4(ipv4)
49
+ }
50
+ return false
51
+ }
52
+
53
+ function isLoopbackHostname(hostname) {
54
+ if (typeof hostname !== 'string') return false
55
+ const value = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
56
+ if (value === 'localhost') return true
57
+ return isLoopbackAddress(value)
58
+ }
59
+
60
+ function isTrustedLocalApiRequest(req, requireOrigin) {
61
+ const remoteAddress = req && req.socket && req.socket.remoteAddress
62
+ if (!isLoopbackAddress(remoteAddress)) return false
63
+ const host = requestHeader(req, 'host')
64
+ if (host === undefined) return false
65
+ let hostUrl
66
+ try {
67
+ hostUrl = new URL('http://' + host)
68
+ } catch (err) {
69
+ return false
70
+ }
71
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
72
+ if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
73
+ const origin = requestHeader(req, 'origin')
74
+ if (origin === undefined) return requireOrigin !== true
75
+ try {
76
+ const originUrl = new URL(origin)
77
+ return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
78
+ } catch (err) {
79
+ return false
80
+ }
81
+ }
82
+
83
+ function hasWriteToken(req, expected) {
84
+ const actual = requestHeader(req, 'x-all-usage-request-token')
85
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
86
+ const actualBytes = Buffer.from(actual)
87
+ const expectedBytes = Buffer.from(expected)
88
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
89
+ }
90
+
91
+ function sendJson(res, code, value) {
92
+ res.statusCode = code
93
+ res.setHeader('content-type', 'application/json; charset=utf-8')
94
+ res.setHeader('cache-control', 'no-store')
95
+ res.end(JSON.stringify(value))
96
+ }
97
+
98
+ function readBody(req, maxBytes) {
99
+ return new Promise((resolve) => {
100
+ const chunks = []
101
+ const declaredLength = Number(requestHeader(req, 'content-length'))
102
+ let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
103
+ let tooLarge = size > maxBytes
104
+ let settled = false
105
+ const finish = (text, oversized) => {
106
+ if (settled) return
107
+ settled = true
108
+ resolve({ text, tooLarge: oversized })
109
+ }
110
+ req.on('data', (chunk) => {
111
+ if (tooLarge) return
112
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
113
+ size += value.length
114
+ if (size > maxBytes) {
115
+ tooLarge = true
116
+ chunks.length = 0
117
+ return
118
+ }
119
+ chunks.push(value)
120
+ })
121
+ req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
122
+ req.on('error', () => finish('', false))
123
+ })
124
+ }
125
+
126
+ export function registerRoutes(host) {
127
+ const { ctx, webServer, state } = host
128
+ const {
129
+ queryScopeFromRequest,
130
+ queryUsageScope,
131
+ queryRecords,
132
+ snapshot,
133
+ statusSnapshot,
134
+ } = host.aggregation
135
+ const {
136
+ pricingModelSearch,
137
+ pricingSnapshot,
138
+ updatePricingState,
139
+ persistPricing,
140
+ syncPricing,
141
+ } = host.pricing
142
+ const { runBaseline } = host.sessionSync
143
+ const { fetchBalance } = host.balance
144
+ const { setAlias } = host.aliases
145
+ const { drainLedgerWrites } = host.ledger
146
+
147
+ // ---------- HTTP data routes for the client half ----------
148
+ if (webServer !== undefined) {
149
+ const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
150
+ ctx.effect(() => webServer.register({
151
+ kind: 'exact',
152
+ path: '/api/all-usage/query',
153
+ handler: (req, res) => {
154
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
155
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
156
+ const parsed = queryScopeFromRequest(req)
157
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
158
+ sendJson(res, 200, queryUsageScope(parsed.scope))
159
+ },
160
+ }))
161
+ ctx.effect(() => webServer.register({
162
+ kind: 'exact',
163
+ path: '/api/all-usage/records',
164
+ handler: (req, res) => {
165
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
166
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
167
+ const parsed = queryScopeFromRequest(req)
168
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
169
+ let limit = 50
170
+ let cursor
171
+ try {
172
+ const url = new URL(req.url || '/', 'http://all-usage.local')
173
+ const rawLimit = url.searchParams.get('limit')
174
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
175
+ cursor = url.searchParams.get('cursor') || undefined
176
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
177
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
178
+ const result = queryRecords(parsed.scope, cursor, limit)
179
+ if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
180
+ sendJson(res, 200, result)
181
+ },
182
+ }))
183
+ ctx.effect(() => webServer.register({
184
+ kind: 'exact',
185
+ path: '/api/all-usage/pricing/models',
186
+ handler: async (req, res) => {
187
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
188
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
189
+ let query = ''
190
+ let limit = 20
191
+ try {
192
+ const url = new URL(req.url || '/', 'http://all-usage.local')
193
+ query = url.searchParams.get('q') || ''
194
+ const rawLimit = url.searchParams.get('limit')
195
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
196
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
197
+ if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
198
+ await state.pricingReady
199
+ sendJson(res, 200, { items: pricingModelSearch(query, limit) })
200
+ },
201
+ }))
202
+ ctx.effect(() => webServer.register({
203
+ kind: 'exact',
204
+ path: '/api/all-usage/pricing',
205
+ handler: async (req, res) => {
206
+ if (req.method === 'GET') {
207
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
208
+ await state.pricingReady
209
+ sendJson(res, 200, pricingSnapshot())
210
+ return
211
+ }
212
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
213
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
214
+ const body = await readBody(req, 256 * 1024)
215
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
216
+ let args = null
217
+ try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
218
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
219
+ // Wait for the persisted pricing state (and the ledger it backfills)
220
+ // before mutating it: an early POST would otherwise be skipped because
221
+ // pricingUnit is still null and then overwritten by the loaded state.
222
+ await Promise.all([state.pricingReady, state.ledgerReady])
223
+ const result = updatePricingState(args.pricing || args, args.backfill === true)
224
+ await persistPricing()
225
+ await drainLedgerWrites()
226
+ sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
227
+ },
228
+ }))
229
+ ctx.effect(() => webServer.register({
230
+ kind: 'exact',
231
+ path: '/api/all-usage/pricing/sync',
232
+ handler: async (req, res) => {
233
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
234
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
235
+ // Backfill iterates the loaded ledger, so wait for it like the pricing
236
+ // POST does; otherwise an early sync answers with an empty backfill.
237
+ await Promise.all([state.pricingReady, state.ledgerReady])
238
+ const result = await syncPricing(true)
239
+ sendJson(res, result.ok ? 200 : 502, result)
240
+ },
241
+ }))
242
+ ctx.effect(() => webServer.register({
243
+ kind: 'exact',
244
+ path: '/api/all-usage/status',
245
+ handler: (req, res) => {
246
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
247
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
248
+ if (!state.scan.started) void runBaseline()
249
+ sendJson(res, 200, statusSnapshot())
250
+ },
251
+ }))
252
+ ctx.effect(() => webServer.register({
253
+ kind: 'exact',
254
+ path: '/api/all-usage',
255
+ handler: async (req, res) => {
256
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
257
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
258
+ // The snapshot embeds the pricing summary; wait for the persisted
259
+ // configuration so the first response is never a default empty state
260
+ // that the client would keep as its pricing baseline.
261
+ await Promise.all([state.ledgerReady, state.pricingReady])
262
+ if (!state.scan.started) void runBaseline()
263
+ sendJson(res, 200, snapshot())
264
+ },
265
+ }))
266
+ ctx.effect(() => webServer.register({
267
+ kind: 'exact',
268
+ path: '/api/all-usage/balance',
269
+ handler: async (req, res) => {
270
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
271
+ // Browsers may omit Origin on same-origin GET; the process token remains required.
272
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
273
+ let force = false
274
+ try {
275
+ const url = new URL(req.url ?? '/', 'http://x')
276
+ force = url.searchParams.get('force') === '1'
277
+ } catch (err) { /* default */ }
278
+ sendJson(res, 200, await fetchBalance(force))
279
+ },
280
+ }))
281
+ ctx.effect(() => webServer.register({
282
+ kind: 'exact',
283
+ path: '/api/all-usage/alias',
284
+ handler: async (req, res) => {
285
+ if (req.method !== 'POST') {
286
+ res.statusCode = 405
287
+ res.end()
288
+ return
289
+ }
290
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
291
+ const body = await readBody(req, 16 * 1024)
292
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
293
+ let args = null
294
+ try {
295
+ args = JSON.parse(body.text)
296
+ } catch (err) { /* invalid json */ }
297
+ const validAliasRequest = args !== null && args !== undefined && typeof args === 'object' && !Array.isArray(args) && typeof args.workspaceId === 'string' && args.workspaceId.length > 0 && args.workspaceId.length <= 256 && typeof args.alias === 'string'
298
+ const result = validAliasRequest
299
+ ? setAlias(args.workspaceId, args.alias)
300
+ : { ok: false, message: 'bad-request', aliases: Object.assign({}, state.aliases) }
301
+ sendJson(res, result.ok ? 200 : 400, result)
302
+ },
303
+ }))
304
+ }
305
+ }