dsh-all-usage 1.1.18 → 1.2.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +469 -394
  2. package/LICENSE +21 -21
  3. package/README.en.md +273 -0
  4. package/README.md +145 -279
  5. package/assets/model-icons/ai2-color.svg +1 -0
  6. package/assets/model-icons/ai21-brand-color.svg +1 -0
  7. package/assets/model-icons/aionlabs.svg +1 -0
  8. package/assets/model-icons/alephalpha.svg +1 -0
  9. package/assets/model-icons/baai.svg +1 -0
  10. package/assets/model-icons/baichuan-color.svg +1 -0
  11. package/assets/model-icons/baidu-color.svg +1 -0
  12. package/assets/model-icons/bytedance-color.svg +1 -0
  13. package/assets/model-icons/cohere-color.svg +1 -0
  14. package/assets/model-icons/hunyuan-color.svg +1 -0
  15. package/assets/model-icons/ibm.svg +1 -0
  16. package/assets/model-icons/iflytekcloud-color.svg +1 -0
  17. package/assets/model-icons/internlm-color.svg +1 -0
  18. package/assets/model-icons/kling-color.svg +1 -0
  19. package/assets/model-icons/liquid.svg +1 -0
  20. package/assets/model-icons/longcat-color.svg +1 -0
  21. package/assets/model-icons/manifest.json +739 -213
  22. package/assets/model-icons/microsoft-color.svg +1 -0
  23. package/assets/model-icons/mistral-color.svg +1 -0
  24. package/assets/model-icons/nousresearch.svg +1 -0
  25. package/assets/model-icons/nova-color.svg +1 -0
  26. package/assets/model-icons/nvidia-color.svg +1 -0
  27. package/assets/model-icons/perplexity-color.svg +1 -0
  28. package/assets/model-icons/sensenova-color.svg +1 -0
  29. package/assets/model-icons/skywork-color.svg +1 -0
  30. package/assets/model-icons/snowflake-color.svg +1 -0
  31. package/assets/model-icons/stepfun-color.svg +1 -0
  32. package/assets/model-icons/tencent-color.svg +1 -0
  33. package/assets/model-icons/tii-color.svg +1 -0
  34. package/assets/model-icons/upstage-color.svg +1 -0
  35. package/assets/model-icons/voyage-color.svg +1 -0
  36. package/assets/model-icons/xiaomimimo.svg +1 -0
  37. package/assets/model-icons/yandex.svg +1 -0
  38. package/assets/readme-architecture-en.svg +95 -0
  39. package/assets/readme-architecture-zh.svg +95 -0
  40. package/assets/readme-sync-en.svg +81 -0
  41. package/assets/readme-sync-zh.svg +81 -0
  42. package/assets/screenshots/en/01-overview.png +0 -0
  43. package/assets/screenshots/en/02-heatmap.png +0 -0
  44. package/assets/screenshots/en/03-model-usage.png +0 -0
  45. package/assets/screenshots/en/04-request-logs.png +0 -0
  46. package/assets/screenshots/en/05-cost-settings.png +0 -0
  47. package/assets/screenshots/zh/01-overview.png +0 -0
  48. package/assets/screenshots/zh/02-heatmap.png +0 -0
  49. package/assets/screenshots/zh/03-model-usage.png +0 -0
  50. package/assets/screenshots/zh/04-request-logs.png +0 -0
  51. package/assets/screenshots/zh/05-cost-settings.png +0 -0
  52. package/cordis.patch.yml +5 -5
  53. package/lib/aggregation.js +1850 -1112
  54. package/lib/client.js +1 -1
  55. package/lib/http.js +417 -385
  56. package/lib/icons.js +40 -0
  57. package/lib/ledger.js +1000 -531
  58. package/lib/plugin.js +348 -327
  59. package/lib/pricing-runtime.js +645 -551
  60. package/lib/pricing.js +1488 -1412
  61. package/lib/records-cache.js +113 -0
  62. package/lib/session-sync.js +976 -841
  63. package/lib/usage-core.js +33 -10
  64. package/lib/version-check.js +133 -133
  65. package/package.json +7 -3
  66. package/screenshots.json +10 -3
  67. package/assets/screenshot-1.png +0 -0
  68. package/assets/screenshot-2.png +0 -0
  69. package/assets/screenshot-3.png +0 -0
package/lib/http.js CHANGED
@@ -1,385 +1,417 @@
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
- // The DSH desktop shell serves the UI from its own scheme and forwards every
61
- // request through Electron, which strips Origin (along with Cookie and
62
- // Sec-Fetch-*) before the host sees it. A write therefore cannot present an
63
- // Origin at all, and requiring one made every save from the desktop client fail
64
- // with 403. What actually authorizes a write is the per-apply capability: it is
65
- // unguessable and unreadable cross-origin (the read endpoints send no CORS
66
- // headers), and a browser cannot attach it to a form POST. An Origin that is
67
- // present must still be a trusted one, and the Host must still be loopback so a
68
- // rebound DNS name cannot reach the API.
69
- function isTrustedLocalOrigin(origin, hostUrl) {
70
- if (origin === 'dsh-app://app' || origin.startsWith('dsh-app://')) return true
71
- let originUrl
72
- try {
73
- originUrl = new URL(origin)
74
- } catch (err) {
75
- return false
76
- }
77
- return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
78
- }
79
-
80
- function isTrustedLocalApiRequest(req, requireOrigin) {
81
- const remoteAddress = req && req.socket && req.socket.remoteAddress
82
- if (!isLoopbackAddress(remoteAddress)) return false
83
- const host = requestHeader(req, 'host')
84
- if (host === undefined) return false
85
- let hostUrl
86
- try {
87
- hostUrl = new URL('http://' + host)
88
- } catch (err) {
89
- return false
90
- }
91
- if (!isLoopbackHostname(hostUrl.hostname)) return false
92
- if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
93
- const origin = requestHeader(req, 'origin')
94
- if (origin === undefined || origin === '') return requireOrigin !== true
95
- return isTrustedLocalOrigin(origin, hostUrl)
96
- }
97
-
98
- function hasWriteToken(req, expected) {
99
- const actual = requestHeader(req, 'x-all-usage-request-token')
100
- if (typeof actual !== 'string' || typeof expected !== 'string') return false
101
- const actualBytes = Buffer.from(actual)
102
- const expectedBytes = Buffer.from(expected)
103
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
104
- }
105
-
106
- function sendJson(res, code, value) {
107
- res.statusCode = code
108
- res.setHeader('content-type', 'application/json; charset=utf-8')
109
- res.setHeader('cache-control', 'no-store')
110
- res.end(JSON.stringify(value))
111
- }
112
-
113
- function readBody(req, maxBytes) {
114
- return new Promise((resolve) => {
115
- const chunks = []
116
- const declaredLength = Number(requestHeader(req, 'content-length'))
117
- let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
118
- let tooLarge = size > maxBytes
119
- let settled = false
120
- const finish = (text, oversized) => {
121
- if (settled) return
122
- settled = true
123
- resolve({ text, tooLarge: oversized })
124
- }
125
- req.on('data', (chunk) => {
126
- if (tooLarge) return
127
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
128
- size += value.length
129
- if (size > maxBytes) {
130
- tooLarge = true
131
- chunks.length = 0
132
- return
133
- }
134
- chunks.push(value)
135
- })
136
- req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
137
- req.on('error', () => finish('', false))
138
- })
139
- }
140
-
141
- export function registerRoutes(host) {
142
- const { ctx, webServer, state } = host
143
- const {
144
- queryScopeFromRequest,
145
- queryUsageScope,
146
- queryRecords,
147
- snapshot,
148
- statusSnapshot,
149
- } = host.aggregation
150
- const {
151
- pricingModelSearch,
152
- pricingSnapshot,
153
- updatePricingState,
154
- persistPricing,
155
- syncPricing,
156
- loadHolidayCalendar,
157
- } = host.pricing
158
- const { runBaseline } = host.sessionSync
159
- const { fetchBalance } = host.balance
160
- const { setAlias } = host.aliases
161
- const { drainLedgerWrites } = host.ledger
162
-
163
- // ---------- HTTP data routes for the client half ----------
164
- if (webServer !== undefined) {
165
- const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
166
- ctx.effect(() => webServer.register({
167
- kind: 'exact',
168
- path: '/api/all-usage/query',
169
- handler: (req, res) => {
170
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
171
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
172
- const parsed = queryScopeFromRequest(req)
173
- if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
174
- sendJson(res, 200, queryUsageScope(parsed.scope))
175
- },
176
- }))
177
- ctx.effect(() => webServer.register({
178
- kind: 'exact',
179
- path: '/api/all-usage/records',
180
- handler: (req, res) => {
181
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
182
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
183
- const parsed = queryScopeFromRequest(req)
184
- if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
185
- let limit = 50
186
- let cursor
187
- try {
188
- const url = new URL(req.url || '/', 'http://all-usage.local')
189
- const rawLimit = url.searchParams.get('limit')
190
- if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
191
- cursor = url.searchParams.get('cursor') || undefined
192
- } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
193
- if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
194
- const result = queryRecords(parsed.scope, cursor, limit)
195
- if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
196
- sendJson(res, 200, result)
197
- },
198
- }))
199
- ctx.effect(() => webServer.register({
200
- kind: 'exact',
201
- path: '/api/all-usage/pricing/models',
202
- handler: async (req, res) => {
203
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
204
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
205
- let query = ''
206
- let limit = 20
207
- try {
208
- const url = new URL(req.url || '/', 'http://all-usage.local')
209
- query = url.searchParams.get('q') || ''
210
- const rawLimit = url.searchParams.get('limit')
211
- if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
212
- } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
213
- if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
214
- await state.pricingReady
215
- sendJson(res, 200, { items: pricingModelSearch(query, limit) })
216
- },
217
- }))
218
- ctx.effect(() => webServer.register({
219
- kind: 'exact',
220
- path: '/api/all-usage/pricing',
221
- handler: async (req, res) => {
222
- if (req.method === 'GET') {
223
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
224
- await state.pricingReady
225
- sendJson(res, 200, pricingSnapshot())
226
- return
227
- }
228
- if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
229
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
230
- const body = await readBody(req, 256 * 1024)
231
- if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
232
- let args = null
233
- try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
234
- if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
235
- // Wait for the persisted pricing state (and the ledger it backfills)
236
- // before mutating it: an early POST would otherwise be skipped because
237
- // pricingUnit is still null and then overwritten by the loaded state.
238
- await Promise.all([state.pricingReady, state.ledgerReady])
239
- const result = updatePricingState(args.pricing || args, args.backfill === true, args.repriceTemporal === true)
240
- await persistPricing()
241
- await drainLedgerWrites()
242
- sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
243
- },
244
- }))
245
- ctx.effect(() => webServer.register({
246
- kind: 'exact',
247
- path: '/api/all-usage/pricing/sync',
248
- handler: async (req, res) => {
249
- if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
250
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
251
- // Backfill iterates the loaded ledger, so wait for it like the pricing
252
- // POST does; otherwise an early sync answers with an empty backfill.
253
- await Promise.all([state.pricingReady, state.ledgerReady])
254
- const result = await syncPricing(true)
255
- sendJson(res, result.ok ? 200 : 502, result)
256
- },
257
- }))
258
- ctx.effect(() => webServer.register({
259
- kind: 'exact',
260
- path: '/api/all-usage/pricing/holidays',
261
- handler: async (req, res) => {
262
- if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
263
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
264
- const body = await readBody(req, 8 * 1024)
265
- if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
266
- let args = null
267
- try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
268
- const year = args !== null && typeof args === 'object' && !Array.isArray(args) ? Number(args.year) : NaN
269
- if (!Number.isSafeInteger(year) || year < 2000 || year > 2100) { sendJson(res, 400, { ok: false, message: 'bad-holiday-request' }); return }
270
- // Fetch only: the client freezes the returned dates into a pricing policy.
271
- const result = await loadHolidayCalendar(year)
272
- sendJson(res, result.ok ? 200 : 502, result)
273
- },
274
- }))
275
- ctx.effect(() => webServer.register({
276
- kind: 'exact',
277
- path: '/api/all-usage/status',
278
- handler: (req, res) => {
279
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
280
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
281
- if (!state.scan.started) void runBaseline()
282
- sendJson(res, 200, statusSnapshot())
283
- },
284
- }))
285
- ctx.effect(() => webServer.register({
286
- kind: 'exact',
287
- path: '/api/all-usage/client-env',
288
- handler: (req, res) => {
289
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
290
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
291
- // Diagnostics only: the client reports what the host exposes (user agent,
292
- // window-controls-overlay geometry, the strip it reserved). Truncated and
293
- // kept in memory for the life of the process.
294
- try {
295
- const url = new URL(req.url ?? '/', 'http://x')
296
- const read = (name, limit) => {
297
- const value = url.searchParams.get(name)
298
- return value === null ? null : value.slice(0, limit)
299
- }
300
- state.clientEnv = {
301
- reportedAt: Date.now(),
302
- ua: read('ua', 200),
303
- wco: read('wco', 40),
304
- inset: read('inset', 12),
305
- insetSource: read('src', 24),
306
- titlebar: read('tb', 24),
307
- viewport: read('vp', 24),
308
- modalRect: read('modal', 40),
309
- dialogRect: read('dlg', 40),
310
- headRect: read('head', 40),
311
- closeRect: read('close', 40),
312
- }
313
- } catch (err) { /* ignore a malformed diagnostic */ }
314
- sendJson(res, 200, { ok: true })
315
- },
316
- }))
317
- ctx.effect(() => webServer.register({
318
- kind: 'exact',
319
- path: '/api/all-usage',
320
- handler: async (req, res) => {
321
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
322
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
323
- // The snapshot embeds the pricing summary; wait for the persisted
324
- // configuration so the first response is never a default empty state
325
- // that the client would keep as its pricing baseline.
326
- await Promise.all([state.ledgerReady, state.pricingReady])
327
- if (!state.scan.started) void runBaseline()
328
- sendJson(res, 200, snapshot())
329
- },
330
- }))
331
- ctx.effect(() => webServer.register({
332
- kind: 'exact',
333
- path: '/api/all-usage/version',
334
- handler: async (req, res) => {
335
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
336
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
337
- let force = false
338
- try {
339
- force = new URL(req.url ?? '/', 'http://x').searchParams.get('force') === '1'
340
- } catch (err) { /* default */ }
341
- // The host caches the answer, so a dashboard left open does not hammer the
342
- // registry; force=1 only comes from the refresh button.
343
- sendJson(res, 200, await host.versionCheck.read(force))
344
- },
345
- }))
346
- ctx.effect(() => webServer.register({
347
- kind: 'exact',
348
- path: '/api/all-usage/balance',
349
- handler: async (req, res) => {
350
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
351
- // Browsers may omit Origin on same-origin GET; the process token remains required.
352
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
353
- let force = false
354
- try {
355
- const url = new URL(req.url ?? '/', 'http://x')
356
- force = url.searchParams.get('force') === '1'
357
- } catch (err) { /* default */ }
358
- sendJson(res, 200, await fetchBalance(force))
359
- },
360
- }))
361
- ctx.effect(() => webServer.register({
362
- kind: 'exact',
363
- path: '/api/all-usage/alias',
364
- handler: async (req, res) => {
365
- if (req.method !== 'POST') {
366
- res.statusCode = 405
367
- res.end()
368
- return
369
- }
370
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
371
- const body = await readBody(req, 16 * 1024)
372
- if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
373
- let args = null
374
- try {
375
- args = JSON.parse(body.text)
376
- } catch (err) { /* invalid json */ }
377
- 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'
378
- const result = validAliasRequest
379
- ? setAlias(args.workspaceId, args.alias)
380
- : { ok: false, message: 'bad-request', aliases: Object.assign({}, state.aliases) }
381
- sendJson(res, result.ok ? 200 : 400, result)
382
- },
383
- }))
384
- }
385
- }
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { findModelIcon } from './icons.js'
3
+
4
+ // local and require a browser-originated capability for state-changing reads/writes.
5
+ function requestHeader(req, name) {
6
+ const headers = req && req.headers
7
+ if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
8
+ const value = headers[name.toLowerCase()]
9
+ if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
10
+ return typeof value === 'string' ? value : undefined
11
+ }
12
+
13
+ function isLoopbackIpv4(address) {
14
+ const parts = address.split('.')
15
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
16
+ }
17
+
18
+ function ipv6Words(address) {
19
+ let value = address.toLowerCase()
20
+ if (value.includes('.')) {
21
+ const separator = value.lastIndexOf(':')
22
+ if (separator < 0) return null
23
+ const ipv4 = value.slice(separator + 1)
24
+ if (!isLoopbackIpv4(ipv4) && !ipv4.split('.').every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null
25
+ const parts = ipv4.split('.').map(Number)
26
+ value = value.slice(0, separator + 1) + (((parts[0] << 8) | parts[1]).toString(16)) + ':' + (((parts[2] << 8) | parts[3]).toString(16))
27
+ }
28
+ const halves = value.split('::')
29
+ if (halves.length > 2) return null
30
+ const left = halves[0] === '' ? [] : halves[0].split(':')
31
+ const right = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : []
32
+ if (halves.length === 1 && left.length !== 8) return null
33
+ const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0
34
+ if (zeroCount < (halves.length === 2 ? 1 : 0)) return null
35
+ const groups = left.concat(Array.from({ length: zeroCount }, () => '0'), right)
36
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null
37
+ return groups.map((group) => Number.parseInt(group, 16))
38
+ }
39
+
40
+ function isLoopbackAddress(address) {
41
+ if (typeof address !== 'string') return false
42
+ const value = address.trim().toLowerCase()
43
+ if (isLoopbackIpv4(value)) return true
44
+ const words = ipv6Words(value)
45
+ if (words === null) return false
46
+ if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return true
47
+ if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
48
+ const ipv4 = [(words[6] >> 8) & 255, words[6] & 255, (words[7] >> 8) & 255, words[7] & 255].join('.')
49
+ return isLoopbackIpv4(ipv4)
50
+ }
51
+ return false
52
+ }
53
+
54
+ function isLoopbackHostname(hostname) {
55
+ if (typeof hostname !== 'string') return false
56
+ const value = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
57
+ if (value === 'localhost') return true
58
+ return isLoopbackAddress(value)
59
+ }
60
+
61
+ // The DSH desktop shell serves the UI from its own scheme and forwards every
62
+ // request through Electron, which strips Origin (along with Cookie and
63
+ // Sec-Fetch-*) before the host sees it. A write therefore cannot present an
64
+ // Origin at all, and requiring one made every save from the desktop client fail
65
+ // with 403. What actually authorizes a write is the per-apply capability: it is
66
+ // unguessable and unreadable cross-origin (the read endpoints send no CORS
67
+ // headers), and a browser cannot attach it to a form POST. An Origin that is
68
+ // present must still be a trusted one, and the Host must still be loopback so a
69
+ // rebound DNS name cannot reach the API.
70
+ function isTrustedLocalOrigin(origin, hostUrl) {
71
+ if (origin === 'dsh-app://app' || origin.startsWith('dsh-app://')) return true
72
+ let originUrl
73
+ try {
74
+ originUrl = new URL(origin)
75
+ } catch (err) {
76
+ return false
77
+ }
78
+ return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
79
+ }
80
+
81
+ function isTrustedLocalApiRequest(req, requireOrigin) {
82
+ const remoteAddress = req && req.socket && req.socket.remoteAddress
83
+ if (!isLoopbackAddress(remoteAddress)) return false
84
+ const host = requestHeader(req, 'host')
85
+ if (host === undefined) return false
86
+ let hostUrl
87
+ try {
88
+ hostUrl = new URL('http://' + host)
89
+ } catch (err) {
90
+ return false
91
+ }
92
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
93
+ if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
94
+ const origin = requestHeader(req, 'origin')
95
+ if (origin === undefined || origin === '') return requireOrigin !== true
96
+ return isTrustedLocalOrigin(origin, hostUrl)
97
+ }
98
+
99
+ function hasWriteToken(req, expected) {
100
+ const actual = requestHeader(req, 'x-all-usage-request-token')
101
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
102
+ const actualBytes = Buffer.from(actual)
103
+ const expectedBytes = Buffer.from(expected)
104
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
105
+ }
106
+
107
+ function sendJson(res, code, value) {
108
+ res.statusCode = code
109
+ res.setHeader('content-type', 'application/json; charset=utf-8')
110
+ res.setHeader('cache-control', 'no-store')
111
+ res.end(JSON.stringify(value))
112
+ }
113
+
114
+ function readBody(req, maxBytes) {
115
+ return new Promise((resolve) => {
116
+ const chunks = []
117
+ const declaredLength = Number(requestHeader(req, 'content-length'))
118
+ let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
119
+ let tooLarge = size > maxBytes
120
+ let settled = false
121
+ const finish = (text, oversized) => {
122
+ if (settled) return
123
+ settled = true
124
+ resolve({ text, tooLarge: oversized })
125
+ }
126
+ req.on('data', (chunk) => {
127
+ if (tooLarge) return
128
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
129
+ size += value.length
130
+ if (size > maxBytes) {
131
+ tooLarge = true
132
+ chunks.length = 0
133
+ return
134
+ }
135
+ chunks.push(value)
136
+ })
137
+ req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
138
+ req.on('error', () => finish('', false))
139
+ })
140
+ }
141
+
142
+ export function registerRoutes(host) {
143
+ const { ctx, webServer, state } = host
144
+ const {
145
+ queryScopeFromRequest,
146
+ queryUsageScope,
147
+ queryRecords,
148
+ snapshot,
149
+ statusSnapshot,
150
+ } = host.aggregation
151
+ const {
152
+ pricingModelSearch,
153
+ pricingSnapshot,
154
+ updatePricingState,
155
+ persistPricing,
156
+ syncPricing,
157
+ loadHolidayCalendar,
158
+ } = host.pricing
159
+ const { runBaseline } = host.sessionSync
160
+ const { fetchBalance } = host.balance
161
+ const { setAlias } = host.aliases
162
+ const { drainLedgerWrites } = host.ledger
163
+
164
+ // ---------- HTTP data routes for the client half ----------
165
+ if (webServer !== undefined) {
166
+ const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
167
+ ctx.effect(() => webServer.register({
168
+ kind: 'exact',
169
+ path: '/api/all-usage/query',
170
+ handler: (req, res) => {
171
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
172
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
173
+ const parsed = queryScopeFromRequest(req)
174
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
175
+ sendJson(res, 200, queryUsageScope(parsed.scope))
176
+ },
177
+ }))
178
+ ctx.effect(() => webServer.register({
179
+ kind: 'exact',
180
+ path: '/api/all-usage/records',
181
+ // queryRecords may read a session document back from the ledger, so this
182
+ // handler is async end to end.
183
+ handler: async (req, res) => {
184
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
185
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
186
+ const parsed = queryScopeFromRequest(req)
187
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
188
+ let limit = 50
189
+ let cursor
190
+ try {
191
+ const url = new URL(req.url || '/', 'http://all-usage.local')
192
+ const rawLimit = url.searchParams.get('limit')
193
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
194
+ cursor = url.searchParams.get('cursor') || undefined
195
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
196
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
197
+ const result = await queryRecords(parsed.scope, cursor, limit)
198
+ if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
199
+ sendJson(res, 200, result)
200
+ },
201
+ }))
202
+ ctx.effect(() => webServer.register({
203
+ kind: 'exact',
204
+ path: '/api/all-usage/pricing/models',
205
+ handler: async (req, res) => {
206
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
207
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
208
+ let query = ''
209
+ let limit = 20
210
+ try {
211
+ const url = new URL(req.url || '/', 'http://all-usage.local')
212
+ query = url.searchParams.get('q') || ''
213
+ const rawLimit = url.searchParams.get('limit')
214
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
215
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
216
+ if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
217
+ await state.pricingReady
218
+ sendJson(res, 200, { items: pricingModelSearch(query, limit) })
219
+ },
220
+ }))
221
+ ctx.effect(() => webServer.register({
222
+ kind: 'exact',
223
+ path: '/api/all-usage/pricing',
224
+ handler: async (req, res) => {
225
+ if (req.method === 'GET') {
226
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
227
+ await state.pricingReady
228
+ sendJson(res, 200, pricingSnapshot())
229
+ return
230
+ }
231
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
232
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
233
+ const body = await readBody(req, 256 * 1024)
234
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
235
+ let args = null
236
+ try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
237
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
238
+ // Wait for the persisted pricing state (and the ledger it backfills)
239
+ // before mutating it: an early POST would otherwise be skipped because
240
+ // pricingUnit is still null and then overwritten by the loaded state.
241
+ await Promise.all([state.pricingReady, state.ledgerReady])
242
+ const result = await updatePricingState(args.pricing || args, args.backfill === true, args.repriceTemporal === true)
243
+ await persistPricing()
244
+ await drainLedgerWrites()
245
+ sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
246
+ },
247
+ }))
248
+ ctx.effect(() => webServer.register({
249
+ kind: 'exact',
250
+ path: '/api/all-usage/pricing/sync',
251
+ handler: async (req, res) => {
252
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
253
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
254
+ // Backfill iterates the loaded ledger, so wait for it like the pricing
255
+ // POST does; otherwise an early sync answers with an empty backfill.
256
+ await Promise.all([state.pricingReady, state.ledgerReady])
257
+ const result = await syncPricing(true)
258
+ sendJson(res, result.ok ? 200 : 502, result)
259
+ },
260
+ }))
261
+ ctx.effect(() => webServer.register({
262
+ kind: 'exact',
263
+ path: '/api/all-usage/pricing/holidays',
264
+ handler: async (req, res) => {
265
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
266
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
267
+ const body = await readBody(req, 8 * 1024)
268
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
269
+ let args = null
270
+ try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
271
+ const year = args !== null && typeof args === 'object' && !Array.isArray(args) ? Number(args.year) : NaN
272
+ if (!Number.isSafeInteger(year) || year < 2000 || year > 2100) { sendJson(res, 400, { ok: false, message: 'bad-holiday-request' }); return }
273
+ // Fetch only: the client freezes the returned dates into a pricing policy.
274
+ const result = await loadHolidayCalendar(year)
275
+ sendJson(res, result.ok ? 200 : 502, result)
276
+ },
277
+ }))
278
+ ctx.effect(() => webServer.register({
279
+ kind: 'exact',
280
+ path: '/api/all-usage/status',
281
+ handler: (req, res) => {
282
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
283
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
284
+ if (!state.scan.started) void runBaseline()
285
+ sendJson(res, 200, statusSnapshot())
286
+ },
287
+ }))
288
+ ctx.effect(() => webServer.register({
289
+ kind: 'exact',
290
+ path: '/api/all-usage/client-env',
291
+ handler: (req, res) => {
292
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
293
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
294
+ // Diagnostics only: the client reports what the host exposes (user agent,
295
+ // window-controls-overlay geometry, the strip it reserved). Truncated and
296
+ // kept in memory for the life of the process.
297
+ try {
298
+ const url = new URL(req.url ?? '/', 'http://x')
299
+ const read = (name, limit) => {
300
+ const value = url.searchParams.get(name)
301
+ return value === null ? null : value.slice(0, limit)
302
+ }
303
+ state.clientEnv = {
304
+ reportedAt: Date.now(),
305
+ ua: read('ua', 200),
306
+ wco: read('wco', 40),
307
+ inset: read('inset', 12),
308
+ insetSource: read('src', 24),
309
+ titlebar: read('tb', 24),
310
+ viewport: read('vp', 24),
311
+ modalRect: read('modal', 40),
312
+ dialogRect: read('dlg', 40),
313
+ headRect: read('head', 40),
314
+ closeRect: read('close', 40),
315
+ }
316
+ } catch (err) { /* ignore a malformed diagnostic */ }
317
+ sendJson(res, 200, { ok: true })
318
+ },
319
+ }))
320
+ ctx.effect(() => webServer.register({
321
+ kind: 'exact',
322
+ path: '/api/all-usage',
323
+ handler: async (req, res) => {
324
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
325
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
326
+ // The snapshot embeds the pricing summary; wait for the persisted
327
+ // configuration so the first response is never a default empty state
328
+ // that the client would keep as its pricing baseline.
329
+ await Promise.all([state.ledgerReady, state.pricingReady])
330
+ if (!state.scan.started) void runBaseline()
331
+ // The browser picks one timezone calendar for its whole session, so it
332
+ // asks for that copy only: the other one was half of the payload.
333
+ let utc = null
334
+ try {
335
+ const raw = new URL(req.url ?? '/', 'http://x').searchParams.get('utc')
336
+ if (raw === '0' || raw === '1') utc = raw === '1'
337
+ } catch (err) { /* default: both copies */ }
338
+ sendJson(res, 200, snapshot(utc))
339
+ },
340
+ }))
341
+ ctx.effect(() => webServer.register({
342
+ kind: 'exact',
343
+ path: '/api/all-usage/icon',
344
+ handler: async (req, res) => {
345
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
346
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
347
+ let key = ''
348
+ try {
349
+ key = new URL(req.url ?? '/', 'http://x').searchParams.get('key') ?? ''
350
+ } catch (err) { /* default */ }
351
+ // Resolution is by manifest key only, so a crafted value can never name a
352
+ // path. The body is immutable per release, hence the long cache.
353
+ const icon = await findModelIcon(key)
354
+ if (icon === null) { sendJson(res, 404, { ok: false, message: 'icon-not-found' }); return }
355
+ res.statusCode = 200
356
+ res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
357
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable')
358
+ res.setHeader('etag', icon.etag)
359
+ res.setHeader('content-length', String(icon.body.length))
360
+ res.end(icon.body)
361
+ },
362
+ }))
363
+ ctx.effect(() => webServer.register({
364
+ kind: 'exact',
365
+ path: '/api/all-usage/version',
366
+ handler: async (req, res) => {
367
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
368
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
369
+ let force = false
370
+ try {
371
+ force = new URL(req.url ?? '/', 'http://x').searchParams.get('force') === '1'
372
+ } catch (err) { /* default */ }
373
+ // The host caches the answer, so a dashboard left open does not hammer the
374
+ // registry; force=1 only comes from the refresh button.
375
+ sendJson(res, 200, await host.versionCheck.read(force))
376
+ },
377
+ }))
378
+ ctx.effect(() => webServer.register({
379
+ kind: 'exact',
380
+ path: '/api/all-usage/balance',
381
+ handler: async (req, res) => {
382
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
383
+ // Browsers may omit Origin on same-origin GET; the process token remains required.
384
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
385
+ let force = false
386
+ try {
387
+ const url = new URL(req.url ?? '/', 'http://x')
388
+ force = url.searchParams.get('force') === '1'
389
+ } catch (err) { /* default */ }
390
+ sendJson(res, 200, await fetchBalance(force))
391
+ },
392
+ }))
393
+ ctx.effect(() => webServer.register({
394
+ kind: 'exact',
395
+ path: '/api/all-usage/alias',
396
+ handler: async (req, res) => {
397
+ if (req.method !== 'POST') {
398
+ res.statusCode = 405
399
+ res.end()
400
+ return
401
+ }
402
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
403
+ const body = await readBody(req, 16 * 1024)
404
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
405
+ let args = null
406
+ try {
407
+ args = JSON.parse(body.text)
408
+ } catch (err) { /* invalid json */ }
409
+ 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'
410
+ const result = validAliasRequest
411
+ ? setAlias(args.workspaceId, args.alias)
412
+ : { ok: false, message: 'bad-request', aliases: Object.assign({}, state.aliases) }
413
+ sendJson(res, result.ok ? 200 : 400, result)
414
+ },
415
+ }))
416
+ }
417
+ }