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/CHANGELOG.md +66 -0
- package/README.md +193 -19
- package/fixtures/usage-events.json +172 -0
- package/lib/aggregation.js +1002 -0
- package/lib/balance.js +112 -0
- package/lib/client.js +1 -2906
- package/lib/http.js +305 -0
- package/lib/index.js +2 -2119
- package/lib/ledger.js +464 -0
- package/lib/plugin.js +276 -0
- package/lib/pricing-runtime.js +282 -0
- package/lib/pricing.js +299 -36
- package/lib/session-sync.js +589 -0
- package/lib/usage-core.js +127 -0
- package/package.json +28 -3
- package/scripts/replay-fixture.mjs +155 -0
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
|
+
}
|