@goodandready/dsh-cost-meter 0.7.6 → 0.7.8

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.
@@ -82,7 +82,7 @@
82
82
  - Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
83
83
 
84
84
  ## History & Decisions
85
- - 2026-09-05 (v0.7.6):
85
+ - 2026-09-05 (v0.7.8):
86
86
  - Hotfix: исправлен ReferenceError (t is not defined) в объекте словаря ru (lib/client.js) (#15).
87
87
  - Добавлен автоматический тест на чистое исполнение фабрики client loader (`test/public-route.test.mjs`).
88
88
  - 2026-09-05 (v0.7.4):
@@ -0,0 +1,10 @@
1
+ # Findings & Architecture Decisions (v0.7.8)
2
+
3
+ ## 1. Browser Request Race Conditions
4
+ In `CostMeter`, rapid model switches or toggling the popover calls `load(usedKeys)`. Since network latency varies, an older request might resolve after a newer one. By tracking an active request counter or cancellation token per effect invocation, we ensure only the most up-to-date state response mutates the component state.
5
+
6
+ ## 2. IANA TimeZone Validation
7
+ `Intl.DateTimeFormat(undefined, { timeZone })` throws a `RangeError` if the timezone string is invalid. We can safely leverage this in `CostMeterCard` to reject typos (e.g. `Europe/Moskow` instead of `Europe/Moscow`) on the client before writing to `settingsScope`.
8
+
9
+ ## 3. Query Parameter Sanitization
10
+ The endpoint `GET /dsh-cost-meter/state?models=...` accepts comma-separated model keys. Adding a length bound (e.g. <= 128 chars) and trimming ensures no malformed payloads cause unexpected overhead.
@@ -0,0 +1,17 @@
1
+ # Implementation Progress (v0.7.8)
2
+
3
+ - [x] Create Gitea Issue #19
4
+ - [x] Create isolated worktree `stability-v078`
5
+ - [x] Bump version to 0.7.8 in `package.json`
6
+ - [x] Add monotonic request sequence guard in `lib/client.js` `CostMeter`
7
+ - [x] Add IANA time zone validation in `lib/client.js` `CostMeterCard`
8
+ - [x] Sanitize query parameters in `lib/index.js`
9
+ - [x] Add automated unit tests (`test/timezone-sanitization.test.mjs`, 11/11 passing)
10
+ - [x] Update design documentation in `docs/design/DESIGN.md`
11
+ - [ ] Commit changes with conventional commits (`git-antigravity`)
12
+ - [ ] Create Pull Request and merge into `main`
13
+ - [ ] Test package on MiniPC isolated test server (`192.168.1.123:3082`)
14
+ - [ ] Smoke-test package on production candidate (`192.168.1.111:3080`)
15
+ - [ ] Publish v0.7.8 to npm and GitHub Release
16
+ - [ ] Install v0.7.8 from npm on MiniAI and verify live health
17
+ - [ ] Close Gitea Issue #19 and clean up worktree
@@ -0,0 +1,24 @@
1
+ # Task Plan: Stability, Race Condition Prevention & TimeZone Validation (v0.7.8)
2
+
3
+ ## 1. Goal
4
+ Strengthen `@goodandready/dsh-cost-meter` runtime resilience:
5
+ - Prevent race condition response overwrites in browser `CostMeter` component.
6
+ - Validate IANA TimeZone inputs in `CostMeterCard` settings before saving.
7
+ - Sanitize `models` query parameter keys in `lib/index.js` route handler.
8
+ - Maintain strict backward compatibility, zero regression in automated tests.
9
+
10
+ ## 2. Checklist
11
+ - [ ] Bump version to `0.7.8` in `package.json`
12
+ - [ ] Implement cancellation flag / abort protection in `lib/client.js` `CostMeter`
13
+ - [ ] Implement `isValidTimeZone` helper and validation check in `CostMeterCard`
14
+ - [ ] Implement key sanitization (length <= 128 chars, alphanumeric/dash/slash) in `lib/index.js`
15
+ - [ ] Add unit tests for timezone check and query sanitization
16
+ - [ ] Run test suite (`npm test`)
17
+ - [ ] Update `docs/design/DESIGN.md`
18
+ - [ ] Conventional Commit & Push
19
+ - [ ] Gitea PR & Merge to `main`
20
+ - [ ] Test on MiniPC test server (`192.168.1.123`)
21
+ - [ ] Production Smoke Verification on MiniAI (`192.168.1.111`)
22
+ - [ ] Publish Tag `v0.7.8`, GitHub Release & npm
23
+ - [ ] Update production profile from npm
24
+ - [ ] Close Gitea Issue #19 and clean up worktree
package/lib/client.js CHANGED
@@ -131,6 +131,7 @@ window.__ModuleLoader__.load({
131
131
  if (document.getElementById('dsh-cost-meter-card-styles')) return
132
132
  const el = document.createElement('style')
133
133
  el.id = 'dsh-cost-meter-card-styles'
134
+ el.dataset.dshPlugin = 'dsh-cost-meter'
134
135
  el.textContent = cardCss
135
136
  document.head.appendChild(el)
136
137
  }
@@ -208,9 +209,14 @@ window.__ModuleLoader__.load({
208
209
  } catch (e) { broken.push('usdRate: ' + (e && e.message || String(e))) }
209
210
  }
210
211
 
211
- try {
212
- await scope.set('displayTimeZone', String(draft.displayTimeZone || 'Europe/Moscow').trim())
213
- } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
212
+ const tzVal = String(draft.displayTimeZone || 'Europe/Moscow').trim()
213
+ if (!isValidTimeZone(tzVal)) {
214
+ broken.push('displayTimeZone: invalid IANA time zone')
215
+ } else {
216
+ try {
217
+ await scope.set('displayTimeZone', tzVal)
218
+ } catch (e) { broken.push('displayTimeZone: ' + (e && e.message || String(e))) }
219
+ }
214
220
 
215
221
  setSaving(false)
216
222
  if (broken.length) {
@@ -384,6 +390,16 @@ window.__ModuleLoader__.load({
384
390
  return String(Math.floor(minute / 60)).padStart(2, '0') + ':' + String(minute % 60).padStart(2, '0')
385
391
  }
386
392
 
393
+ function isValidTimeZone(tz) {
394
+ if (!tz || typeof tz !== 'string') return false
395
+ try {
396
+ new Intl.DateTimeFormat(undefined, { timeZone: tz })
397
+ return true
398
+ } catch {
399
+ return false
400
+ }
401
+ }
402
+
387
403
  /**
388
404
  * Render a UTC minute-of-day in the display zone. Built from a real Date so
389
405
  * the zone's current offset (and any DST) comes from Intl, not arithmetic.
@@ -471,6 +487,14 @@ window.__ModuleLoader__.load({
471
487
  const [open, setOpen] = React.useState(false)
472
488
  const [now, setNow] = React.useState(() => new Date())
473
489
  const box = React.useRef(null)
490
+ const settingsScope = ctx.settingsScope || (ctx.services && ctx.services.settingsScope)
491
+ const settingsSnap = React.useSyncExternalStore
492
+ ? React.useSyncExternalStore(
493
+ (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
494
+ () => (settingsScope && typeof settingsScope.get === 'function' ? settingsScope.get(NS) : null),
495
+ () => null,
496
+ )
497
+ : null
474
498
 
475
499
  // Every model this session actually billed, so each part is priced on its
476
500
  // own tariff instead of the current model's. Usage arrives split into
@@ -482,14 +506,18 @@ window.__ModuleLoader__.load({
482
506
  m.usage.cacheReadTokens || m.usage.cacheWriteTokens)
483
507
  const usedKeys = used.map((m) => m.provider + '/' + m.model).sort().join(',')
484
508
 
509
+ const reqSeq = React.useRef(0)
510
+
485
511
  const load = React.useCallback((keys) => {
512
+ const seq = ++reqSeq.current
486
513
  const query = keys ? '?models=' + encodeURIComponent(keys) : ''
487
514
  fetch(STATE_PATH + query, { headers: { accept: 'application/json' } })
488
515
  .then((r) => (r.ok ? r.json() : null))
489
- .then((data) => { if (data) setState(data) })
516
+ .then((data) => {
517
+ if (data && seq === reqSeq.current) setState(data)
518
+ })
490
519
  .catch(() => {})
491
520
  }, [])
492
-
493
521
  // Refetch when the set of billed models changes — a new model needs its
494
522
  // own tariff before its tokens can be priced.
495
523
  React.useEffect(() => { load(usedKeys) }, [load, usedKeys])
@@ -507,8 +535,9 @@ window.__ModuleLoader__.load({
507
535
 
508
536
  if (!state) return null
509
537
 
510
- const currency = state.currency || '$'
511
- const fx = Number(state.usdRate) || 1
538
+ const stored = (settingsSnap && settingsSnap.value) || {}
539
+ const currency = stored.currency !== undefined ? String(stored.currency) : (state.currency || '$')
540
+ const fx = stored.usdRate !== undefined && !isNaN(Number(stored.usdRate)) ? Number(stored.usdRate) : (Number(state.usdRate) || 1)
512
541
  const schedule = state.schedule
513
542
  const hasUsage = usage !== undefined && usage !== null
514
543
  const minute = now.getUTCHours() * 60 + now.getUTCMinutes()
@@ -734,27 +763,12 @@ window.__ModuleLoader__.load({
734
763
  ),
735
764
  )
736
765
 
737
- let placed = false
738
- ctx.slots.inject('settings.plugin.item', () => {
739
- placed = true
740
- return ctx.slots.register(
766
+ ctx.slots.inject('settings.plugin.item', () =>
767
+ ctx.slots.register(
741
768
  { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
742
769
  (p) => React.createElement(CostMeterCard, { ...p, ctx }),
743
- )
744
- })
745
-
746
- // Fallback to settings.section if settings.plugin.item is not declared
747
- setTimeout(() => {
748
- if (placed) return
749
- try {
750
- ctx.slots.inject('settings.section', () =>
751
- ctx.slots.register(
752
- { name: 'settings.section', id: '@goodandready/dsh-cost-meter', order: 25, locale: NS, label: () => makeT(useActiveLocale(ctx) === 'ru' ? ru : en, en)('title'), inject: () => ({ ctx }) },
753
- (p) => React.createElement(CostMeterCard, { ...p, ctx }),
754
- ),
755
- )
756
- } catch (_) {}
757
- }, 500)
770
+ ),
771
+ )
758
772
  }
759
773
 
760
774
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
package/lib/index.js CHANGED
@@ -507,16 +507,18 @@ export function apply(ctx, config) {
507
507
  // session that switched models can price each part on its own tariff.
508
508
  const schedules = {}
509
509
  const asked = new URL(req.url, 'http://localhost').searchParams.get('models')
510
- for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
511
- const parsed = splitRoute(key.trim())
512
- if (!parsed) continue
513
- const resolved = resolveTariff(cfg, catalog.index, parsed)
514
- schedules[key.trim()] = {
515
- schedule: resolved.schedule,
516
- source: resolved.source,
517
- matchedId: resolved.matchedId,
518
- }
519
- }
510
+ for (const key of (asked ? asked.split(',') : []).slice(0, 24)) {
511
+ const trimmed = String(key || '').trim()
512
+ if (!trimmed || trimmed.length > 128) continue
513
+ const parsed = splitRoute(trimmed)
514
+ if (!parsed) continue
515
+ const resolved = resolveTariff(cfg, catalog.index, parsed)
516
+ schedules[trimmed] = {
517
+ schedule: resolved.schedule,
518
+ source: resolved.source,
519
+ matchedId: resolved.matchedId,
520
+ }
521
+ }
520
522
 
521
523
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
522
524
  res.end(JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",