tokenrace 0.1.8 → 0.1.10

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/dist/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>tokenrace</title>
7
- <script type="module" crossorigin src="/assets/index-Bq_1mbQ9.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DQk3XNu9.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-B0dy8dWP.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenrace",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Monitor en tiempo real para Claude Code",
5
5
  "bin": {
6
6
  "tokenrace": "bin/cli.js"
package/src/api-routes.js CHANGED
@@ -40,17 +40,37 @@ export function broadcast(type, payload) {
40
40
 
41
41
  /**
42
42
  * Crea y devuelve el router Express con todas las rutas /api/*.
43
+ * @param {{ port?: number }} options
43
44
  * @returns {Router}
44
45
  */
45
- export function createRouter() {
46
+ export function createRouter({ port = 1337 } = {}) {
46
47
  const router = Router()
47
48
 
48
- // Middleware CORS para todas las rutas del router
49
+ // Solo se permiten solicitudes del propio dashboard (mismo origen)
50
+ const allowedOrigins = new Set([
51
+ `http://localhost:${port}`,
52
+ `http://127.0.0.1:${port}`,
53
+ ])
54
+
55
+ // CORS: responder solo al origen del dashboard, nunca con wildcard
49
56
  router.use((req, res, next) => {
50
- res.setHeader('Access-Control-Allow-Origin', '*')
57
+ const origin = req.headers.origin
58
+ if (origin && allowedOrigins.has(origin)) {
59
+ res.setHeader('Access-Control-Allow-Origin', origin)
60
+ }
51
61
  next()
52
62
  })
53
63
 
64
+ // Guard CSRF: bloquea POST con Origin presente pero no permitido.
65
+ // Requests sin Origin (CLI, tests, curl) pasan sin restricción.
66
+ function requireSafeOrigin(req, res, next) {
67
+ const origin = req.headers.origin
68
+ if (origin && !allowedOrigins.has(origin)) {
69
+ return res.status(403).json({ error: 'origen no permitido' })
70
+ }
71
+ return next()
72
+ }
73
+
54
74
  // ── SSE ────────────────────────────────────────────────────────────────────
55
75
 
56
76
  /**
@@ -61,7 +81,6 @@ export function createRouter() {
61
81
  res.setHeader('Content-Type', 'text/event-stream')
62
82
  res.setHeader('Cache-Control', 'no-cache')
63
83
  res.setHeader('Connection', 'keep-alive')
64
- res.setHeader('Access-Control-Allow-Origin', '*')
65
84
  res.flushHeaders()
66
85
 
67
86
  const clientId = ++_nextClientId
@@ -111,7 +130,7 @@ export function createRouter() {
111
130
  /** GET /api/sessions — lista de sesiones, acepta ?limit, ?project */
112
131
  router.get('/api/sessions', (req, res) => {
113
132
  res.json(getSessions({
114
- limit: req.query.limit ? Number(req.query.limit) : 50,
133
+ limit: Math.min(Number(req.query.limit) || 50, 500),
115
134
  project: req.query.project || null
116
135
  }))
117
136
  })
@@ -124,7 +143,7 @@ export function createRouter() {
124
143
  /** GET /api/events — eventos filtrados, acepta ?limit, ?type, ?project */
125
144
  router.get('/api/events', (req, res) => {
126
145
  res.json(getEvents({
127
- limit: req.query.limit ? Number(req.query.limit) : 200,
146
+ limit: Math.min(Number(req.query.limit) || 200, 500),
128
147
  type: req.query.type || null,
129
148
  project: req.query.project || null
130
149
  }))
@@ -150,10 +169,10 @@ export function createRouter() {
150
169
  * Asigna un proyecto a una sesión.
151
170
  * Body: { project: string }
152
171
  */
153
- router.post('/api/sessions/:sessionId/label', (req, res) => {
172
+ router.post('/api/sessions/:sessionId/label', requireSafeOrigin, (req, res) => {
154
173
  const { project } = req.body
155
- if (!project || typeof project !== 'string') {
156
- return res.status(400).json({ error: 'project requerido' })
174
+ if (!project || typeof project !== 'string' || project.length > 200) {
175
+ return res.status(400).json({ error: 'project inválido' })
157
176
  }
158
177
  labelSession(req.params.sessionId, project)
159
178
  broadcast('label_updated', { sessionId: req.params.sessionId, project })
@@ -164,13 +183,13 @@ export function createRouter() {
164
183
  * POST /api/sessions/:sessionId/ignore
165
184
  * Marca una sesión como ignorada (no aparecerá en notificaciones ni en métricas).
166
185
  */
167
- router.post('/api/sessions/:sessionId/ignore', (req, res) => {
186
+ router.post('/api/sessions/:sessionId/ignore', requireSafeOrigin, (req, res) => {
168
187
  ignoreSession(req.params.sessionId)
169
188
  res.json({ ok: true })
170
189
  })
171
190
 
172
191
  /** POST /api/reset — resetea todo el estado en memoria */
173
- router.post('/api/reset', (req, res) => {
192
+ router.post('/api/reset', requireSafeOrigin, (req, res) => {
174
193
  reset()
175
194
  res.json({ ok: true })
176
195
  })
@@ -21,6 +21,7 @@ export function extractAttributes(attrs) {
21
21
  for (const attr of attrs) {
22
22
  const { key, value } = attr
23
23
  if (!key || !value) continue
24
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
24
25
 
25
26
  if ('stringValue' in value) {
26
27
  result[key] = value.stringValue
package/src/server.js CHANGED
@@ -31,6 +31,14 @@ export async function startServer({ port = 1337 } = {}) {
31
31
  const app = express()
32
32
  app.use(express.json({ limit: '10mb' }))
33
33
 
34
+ // Headers de seguridad HTTP mínimos
35
+ app.use((_req, res, next) => {
36
+ res.setHeader('X-Content-Type-Options', 'nosniff')
37
+ res.setHeader('X-Frame-Options', 'DENY')
38
+ res.setHeader('Referrer-Policy', 'no-referrer')
39
+ next()
40
+ })
41
+
34
42
  // ── Endpoints OTLP ──────────────────────────────────────────────────────────
35
43
 
36
44
  /**
@@ -83,7 +91,7 @@ export async function startServer({ port = 1337 } = {}) {
83
91
  })
84
92
 
85
93
  // ── API REST + SSE ──────────────────────────────────────────────────────────
86
- app.use(createRouter())
94
+ app.use(createRouter({ port }))
87
95
 
88
96
  // ── Archivos estáticos (web compilada) ──────────────────────────────────────
89
97
  // dist/ está en la raíz del proyecto (un nivel arriba de src/)
@@ -111,7 +119,7 @@ export async function startServer({ port = 1337 } = {}) {
111
119
 
112
120
  // ── Arrancar servidor ───────────────────────────────────────────────────────
113
121
  return new Promise((resolve) => {
114
- const server = app.listen(port, () => {
122
+ const server = app.listen(port, '127.0.0.1', () => {
115
123
  resolve({ app, server, autoSaveInterval })
116
124
  })
117
125
  })
package/src/store.js CHANGED
@@ -28,23 +28,90 @@ export function setDataPathForTesting(newPath) {
28
28
  // ─── Estado en memoria ───────────────────────────────────────────────────────
29
29
 
30
30
  const state = {
31
- timeseries: new Map(), // metricName → [{ ts, value, labels }]
32
- sessions: new Map(), // sessionId → SessionData
33
- sessionMappings: new Map(), // sessionId → projectName
34
- ignoredSessions: new Set(), // sessionIds ignorados
35
- events: [], // buffer circular, max 1000
36
- eventIndex: 0,
37
- projects: new Map(), // projectName → ProjectAggregates
38
- tools: new Map(), // toolName → ToolStats
39
- agents: new Map(), // agentId → AgentData
40
- models: new Map(), // modelName → ModelStats
41
- lastSeen: null,
42
- startTime: Date.now(),
43
- totalEvents: 0
31
+ timeseries: new Map(), // metricName → [{ ts, value, labels }]
32
+ sessions: new Map(), // sessionId → SessionData
33
+ sessionMappings: new Map(), // sessionId → projectName
34
+ ignoredSessions: new Set(), // sessionIds ignorados
35
+ events: [], // buffer circular, max 1000
36
+ eventIndex: 0,
37
+ projects: new Map(), // projectName → ProjectAggregates
38
+ tools: new Map(), // toolName → ToolStats
39
+ agents: new Map(), // agentId → AgentData
40
+ models: new Map(), // modelName → ModelStats
41
+ cumulativeValues: new Map(), // clave → último valor acumulativo (no persiste)
42
+ lastSeen: null,
43
+ startTime: Date.now(),
44
+ totalEvents: 0
44
45
  }
45
46
 
46
47
  // ─── Helpers internos ────────────────────────────────────────────────────────
47
48
 
49
+ /**
50
+ * Convierte un valor acumulativo en un delta desde la última lectura.
51
+ * Se usa para métricas que Claude Code exporta con temporalidad cumulativa.
52
+ */
53
+ function cumulativeDelta(key, newValue) {
54
+ const last = state.cumulativeValues.get(key) ?? 0
55
+ const delta = Math.max(0, newValue - last)
56
+ state.cumulativeValues.set(key, newValue)
57
+ return delta
58
+ }
59
+
60
+ /**
61
+ * Normaliza los nombres de métricas nuevos de Claude Code a los nombres
62
+ * canónicos que usa el store, convirtiendo valores cumulativos a deltas.
63
+ * Devuelve { name, value } normalizado, o null para ignorar la métrica.
64
+ */
65
+ function normalizeIncoming(name, value, labels) {
66
+ const sid = labels['session.id'] ?? ''
67
+
68
+ switch (name) {
69
+ case 'claude_code.token.usage': {
70
+ const type = labels.type
71
+ const key = `token:${sid}:${type}:${labels.model ?? ''}:${labels.query_source ?? ''}`
72
+ const delta = cumulativeDelta(key, value)
73
+ const nameMap = {
74
+ 'input': 'claude_code.tokens.input',
75
+ 'output': 'claude_code.tokens.output',
76
+ 'cacheRead': 'claude_code.tokens.cache.read',
77
+ 'cacheCreation': 'claude_code.tokens.cache.creation',
78
+ }
79
+ const canonical = nameMap[type]
80
+ return canonical ? { name: canonical, value: delta } : null
81
+ }
82
+
83
+ case 'claude_code.cost.usage': {
84
+ const key = `cost:${sid}:${labels.model ?? ''}:${labels.query_source ?? ''}:${labels.effort ?? ''}`
85
+ const delta = cumulativeDelta(key, value)
86
+ return { name: 'claude_code.cost', value: delta }
87
+ }
88
+
89
+ case 'claude_code.lines_of_code.count': {
90
+ const type = labels.type
91
+ const key = `lines:${sid}:${type}`
92
+ const delta = cumulativeDelta(key, value)
93
+ if (type === 'added') return { name: 'claude_code.lines_added', value: delta }
94
+ if (type === 'removed') return { name: 'claude_code.lines_removed', value: delta }
95
+ return null
96
+ }
97
+
98
+ case 'claude_code.commit.count': {
99
+ const key = `commit:${sid}`
100
+ const delta = cumulativeDelta(key, value)
101
+ return { name: 'claude_code.commits', value: delta }
102
+ }
103
+
104
+ // Métricas que no necesitamos agregar (activo ya se calcula desde eventos)
105
+ case 'claude_code.session.count':
106
+ case 'claude_code.active_time.total':
107
+ case 'claude_code.code_edit_tool.decision':
108
+ return null
109
+
110
+ default:
111
+ return { name, value }
112
+ }
113
+ }
114
+
48
115
  /**
49
116
  * Resuelve el proyecto de una sesión.
50
117
  * Prioridad: mapping manual > resource project > null
@@ -137,7 +204,12 @@ function scheduleSave() {
137
204
  * Procesa un punto de métrica normalizado.
138
205
  * @param {{ name, value, timestamp, labels }} metric
139
206
  */
140
- export function processMetric({ name, value, timestamp, labels }) {
207
+ export function processMetric(raw) {
208
+ const normalized = normalizeIncoming(raw.name, raw.value, raw.labels)
209
+ if (!normalized) return
210
+
211
+ const { name, value } = normalized
212
+ const { timestamp, labels } = raw
141
213
  const sessionId = labels['session.id']
142
214
 
143
215
  // Saltar sesiones ignoradas
@@ -148,7 +220,10 @@ export function processMetric({ name, value, timestamp, labels }) {
148
220
 
149
221
  // ── Timeseries ──
150
222
  if (!state.timeseries.has(name)) state.timeseries.set(name, [])
151
- state.timeseries.get(name).push({ ts: timestamp, value, labels })
223
+ const tsPoints = state.timeseries.get(name)
224
+ tsPoints.push({ ts: timestamp, value, labels })
225
+ // Cap por métrica para prevenir DoS por agotamiento de memoria
226
+ if (tsPoints.length > 10_000) tsPoints.splice(0, tsPoints.length - 10_000)
152
227
 
153
228
  // ── Sesiones ──
154
229
  if (sessionId) {
@@ -364,15 +439,9 @@ export function labelSession(sessionId, project) {
364
439
  state.sessions.get(sessionId).project = project
365
440
  }
366
441
 
367
- // Aplicar retroactivamente en timeseries
368
- for (const points of state.timeseries.values()) {
369
- for (const point of points) {
370
- if (point.labels['session.id'] === sessionId) {
371
- point.labels.project = project
372
- }
373
- }
374
- }
375
-
442
+ // No es necesario propagar retroactivamente a los labels de timeseries:
443
+ // resolveProject() consulta sessionMappings primero, por lo que todos los
444
+ // lectores de labels.project ya obtendrán el valor correcto.
376
445
  rebuildProjectAggregates()
377
446
  scheduleSave()
378
447
  }
@@ -400,6 +469,7 @@ export function reset() {
400
469
  state.tools.clear()
401
470
  state.agents.clear()
402
471
  state.models.clear()
472
+ state.cumulativeValues.clear()
403
473
  state.lastSeen = null
404
474
  state.startTime = Date.now()
405
475
  state.totalEvents = 0
@@ -413,13 +483,16 @@ export function reset() {
413
483
  * Estado de conexión del servidor.
414
484
  */
415
485
  export function getStatus() {
486
+ let sessionCount = 0
487
+ for (const id of state.sessions.keys()) {
488
+ if (!state.ignoredSessions.has(id)) sessionCount++
489
+ }
416
490
  return {
417
- connected: state.lastSeen !== null,
418
- lastSeen: state.lastSeen,
419
- sessionCount: Array.from(state.sessions.keys())
420
- .filter(id => !state.ignoredSessions.has(id)).length,
421
- totalEvents: state.totalEvents,
422
- uptime: Date.now() - state.startTime
491
+ connected: state.lastSeen !== null,
492
+ lastSeen: state.lastSeen,
493
+ sessionCount,
494
+ totalEvents: state.totalEvents,
495
+ uptime: Date.now() - state.startTime
423
496
  }
424
497
  }
425
498
 
@@ -514,8 +587,24 @@ export function getProjects(from) {
514
587
  const minTs = parseTimeRange(from)
515
588
  const result = []
516
589
 
590
+ // Pre-agregar commits/líneas desde timeseries en un único recorrido O(puntos)
591
+ // en lugar de O(proyectos × puntos)
592
+ const tsAgg = new Map() // projectName → { commits, linesAdded, linesRemoved }
593
+ for (const [metric, field] of [
594
+ ['claude_code.commits', 'commits'],
595
+ ['claude_code.lines_added', 'linesAdded'],
596
+ ['claude_code.lines_removed', 'linesRemoved'],
597
+ ]) {
598
+ for (const point of state.timeseries.get(metric) ?? []) {
599
+ if (point.ts < minTs) continue
600
+ const proj = resolveProject(point.labels['session.id'], point.labels.project)
601
+ if (!proj) continue
602
+ if (!tsAgg.has(proj)) tsAgg.set(proj, { commits: 0, linesAdded: 0, linesRemoved: 0 })
603
+ tsAgg.get(proj)[field] += point.value
604
+ }
605
+ }
606
+
517
607
  for (const [project, proj] of state.projects.entries()) {
518
- // Filtrar sesiones activas en el rango
519
608
  const activeSessions = new Set()
520
609
  let cost = 0, tokensInput = 0, tokensOutput = 0, tokensCache = 0
521
610
 
@@ -531,20 +620,7 @@ export function getProjects(from) {
531
620
 
532
621
  if (activeSessions.size === 0 && minTs > 0) continue
533
622
 
534
- // Commits y líneas filtrados por rango
535
- let commits = 0, linesAdded = 0, linesRemoved = 0
536
- const tsMap = {
537
- 'claude_code.commits': (v) => { commits += v },
538
- 'claude_code.lines_added': (v) => { linesAdded += v },
539
- 'claude_code.lines_removed': (v) => { linesRemoved += v }
540
- }
541
- for (const [metric, accum] of Object.entries(tsMap)) {
542
- for (const point of state.timeseries.get(metric) ?? []) {
543
- const pointProject = resolveProject(point.labels['session.id'], point.labels.project)
544
- if (pointProject === project && point.ts >= minTs) accum(point.value)
545
- }
546
- }
547
-
623
+ const { commits, linesAdded, linesRemoved } = tsAgg.get(project) ?? { commits: 0, linesAdded: 0, linesRemoved: 0 }
548
624
  const cacheHitRate = tokensInput > 0 ? tokensCache / tokensInput : 0
549
625
 
550
626
  result.push({
@@ -693,7 +769,7 @@ export function getModels(from) {
693
769
  */
694
770
  export function saveSync() {
695
771
  try {
696
- fs.mkdirSync(dataDir, { recursive: true })
772
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 })
697
773
 
698
774
  const data = {
699
775
  timeseries: Array.from(state.timeseries.entries()),
@@ -706,7 +782,11 @@ export function saveSync() {
706
782
  startTime: state.startTime
707
783
  }
708
784
 
709
- fs.writeFileSync(dataFile, JSON.stringify(data))
785
+ fs.writeFileSync(dataFile, JSON.stringify(data), { mode: 0o600 })
786
+ // Forzar permisos en archivos ya existentes (mode en writeFileSync
787
+ // solo aplica al crear el archivo, no al sobreescribirlo)
788
+ try { fs.chmodSync(dataDir, 0o700) } catch {}
789
+ try { fs.chmodSync(dataFile, 0o600) } catch {}
710
790
  } catch (err) {
711
791
  console.error('[store] Error guardando datos en disco:', err.message)
712
792
  }
@@ -763,18 +843,12 @@ export function loadFromDisk() {
763
843
  state.startTime = data.startTime
764
844
  }
765
845
 
766
- // Re-aplicar mappings a sesiones y timeseries
846
+ // Re-aplicar mappings a sesiones (timeseries no necesitan propagación:
847
+ // resolveProject() consulta sessionMappings primero en todos los lectores)
767
848
  for (const [sessionId, project] of state.sessionMappings.entries()) {
768
849
  if (state.sessions.has(sessionId)) {
769
850
  state.sessions.get(sessionId).project = project
770
851
  }
771
- for (const points of state.timeseries.values()) {
772
- for (const point of points) {
773
- if (point.labels['session.id'] === sessionId) {
774
- point.labels.project = project
775
- }
776
- }
777
- }
778
852
  }
779
853
 
780
854
  rebuildProjectAggregates()