dsh-token-use 0.2.1 → 0.3.0

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/cordis.patch.yml CHANGED
@@ -12,3 +12,8 @@
12
12
  enabled: true
13
13
  currency: CNY
14
14
  refreshHour: 12
15
+ # Version checks for the configuration tab read npm's packument every six
16
+ # hours; point `registry` at a mirror on a slow or blocked network.
17
+ versions:
18
+ registry: https://registry.npmjs.org
19
+ ttlMs: 21600000
package/lib/health.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Harness self-check: the failure modes a plugin install can leave behind.
3
+ *
4
+ * A broken profile rarely announces itself — the service starts, the panel
5
+ * loads, and one plugin is silently missing (its entry file never shipped, its
6
+ * bundle patch is gone, or two copies of cordis fight over the same hooks).
7
+ * This module walks the running profile on disk, so the panel can say which
8
+ * plugin is incomplete and why. What the *browser* can actually load is checked
9
+ * on the client side, where the boot manifest lives.
10
+ *
11
+ * The report is data, not prose: rows carry a `code`/`hint` the client
12
+ * translates, and `detail` holds only language-neutral facts (paths, versions).
13
+ *
14
+ * Everything here is read-only, and every probe is wrapped: a check that cannot
15
+ * run reports `unknown` rather than breaking the report.
16
+ *
17
+ * @module dsh-token-use/health
18
+ */
19
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import { compareVersions, installedVersion } from './versions.js'
22
+
23
+ /** A plugin's `dsh` block, tolerant of a missing or oddly shaped one. */
24
+ function dshBlock(pkg) {
25
+ const block = pkg?.dsh
26
+ return block !== null && typeof block === 'object' ? block : {}
27
+ }
28
+
29
+ /** Worst-first status across rows. */
30
+ function worst(items, fallback = 'ok') {
31
+ if (items.length === 0) return fallback
32
+ if (items.some((item) => item.status === 'error')) return 'error'
33
+ if (items.some((item) => item.status === 'warn')) return 'warn'
34
+ if (items.every((item) => item.status === 'unknown')) return 'unknown'
35
+ return 'ok'
36
+ }
37
+
38
+ function readJson(file) {
39
+ try {
40
+ return JSON.parse(readFileSync(file, 'utf8'))
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /** The profile whose dependency list names this plugin. */
47
+ export function findProfile(dshHome, name) {
48
+ const root = join(dshHome, 'profiles')
49
+ let entries
50
+ try {
51
+ entries = readdirSync(root)
52
+ } catch {
53
+ return undefined
54
+ }
55
+ for (const entry of entries) {
56
+ const dir = join(root, entry)
57
+ const pkg = readJson(join(dir, 'package.json'))
58
+ if (pkg === undefined) continue
59
+ const deps = Object.keys(pkg.dependencies ?? {})
60
+ const bundles = dshBlock(pkg).profile?.bundles ?? []
61
+ if (deps.includes(name) || bundles.includes(name)) return { name: entry, dir, pkg }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ /** One installed package inside the profile, symlinks resolved. */
67
+ function packageIn(profile, name) {
68
+ const link = join(profile.dir, 'node_modules', name)
69
+ const pkg = readJson(join(link, 'package.json'))
70
+ if (pkg === undefined) return undefined
71
+ let dir = link
72
+ try {
73
+ dir = realpathSync(link)
74
+ } catch {
75
+ // keep the un-resolved path: it exists, we just cannot canonicalise it
76
+ }
77
+ return { dir, pkg, linked: dir !== link }
78
+ }
79
+
80
+ /** The file a loader resolves for a subpath such as `.` or `./client`. */
81
+ function subpathEntry(dir, pkg, subpath) {
82
+ const exports = pkg.exports
83
+ const direct = typeof exports === 'string' && subpath === '.' ? exports : undefined
84
+ const field = typeof exports === 'object' && exports !== null ? exports[subpath] : undefined
85
+ const rel = direct
86
+ ?? (typeof field === 'string' ? field : field?.default)
87
+ ?? (subpath === '.' ? pkg.main : undefined)
88
+ if (typeof rel !== 'string' || rel.length === 0) return undefined
89
+ return join(dir, rel)
90
+ }
91
+
92
+ /** Every `@deepseek-ai/cordis` copy reachable from `fromDir`, with its version. */
93
+ function cordisFrom(fromDir) {
94
+ let dir = fromDir
95
+ for (let i = 0; i < 12; i++) {
96
+ const file = join(dir, 'node_modules', '@deepseek-ai', 'cordis', 'package.json')
97
+ if (existsSync(file)) {
98
+ const pkg = readJson(file)
99
+ let real = file
100
+ try {
101
+ real = realpathSync(file)
102
+ } catch {
103
+ // keep the unresolved path
104
+ }
105
+ return { real, version: typeof pkg?.version === 'string' ? pkg.version : 'unknown' }
106
+ }
107
+ const parent = dirname(dir)
108
+ if (parent === dir) break
109
+ dir = parent
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ /**
115
+ * Walk the profile that runs this plugin and report every plugin-level problem
116
+ * that could keep it out of the running service.
117
+ *
118
+ * @param options - `dshHome`, the plugin `name` and the running `commandVersion`.
119
+ * @returns `{ ok, profile, checks }` — checks group per-plugin rows.
120
+ */
121
+ export function checkHarness({ dshHome, name, commandVersion }) {
122
+ const checks = []
123
+ const profile = findProfile(dshHome, name)
124
+ if (profile === undefined) {
125
+ return {
126
+ ok: false,
127
+ profile: null,
128
+ // The summary already names the searched root, so no hint is needed here.
129
+ checks: [{ id: 'profile', status: 'error', hint: null, data: { root: join(dshHome, 'profiles') }, items: [] }],
130
+ }
131
+ }
132
+
133
+ const integrity = []
134
+ const nodeModules = join(profile.dir, 'node_modules')
135
+ const hasModules = existsSync(nodeModules)
136
+ integrity.push({
137
+ name: 'node_modules',
138
+ status: hasModules ? 'ok' : 'error',
139
+ code: hasModules ? null : 'modules-missing',
140
+ detail: nodeModules,
141
+ hint: hasModules ? null : 'install',
142
+ })
143
+ for (const lock of ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) {
144
+ if (existsSync(join(profile.dir, lock))) integrity.push({ name: lock, status: 'ok', code: null, detail: lock })
145
+ }
146
+ const bundles = dshBlock(profile.pkg).profile?.bundles ?? []
147
+ integrity.push({
148
+ name: 'bundles',
149
+ status: bundles.length > 0 ? 'ok' : 'warn',
150
+ code: bundles.length > 0 ? null : 'bundles-empty',
151
+ detail: String(bundles.length),
152
+ hint: bundles.length > 0 ? null : 'bundles-empty',
153
+ })
154
+ checks.push({
155
+ id: 'profile',
156
+ status: worst(integrity),
157
+ data: { name: profile.name, dir: profile.dir },
158
+ items: integrity,
159
+ })
160
+
161
+ // Per-plugin files on disk. Harness bundles (@deepseek-ai/…) are supplied by
162
+ // the service package rather than the profile, so they are not files to check.
163
+ const items = []
164
+ const cordisCopies = new Map()
165
+ const rememberCordis = (label, fromDir) => {
166
+ const found = cordisFrom(fromDir)
167
+ if (found === undefined) return
168
+ if (!cordisCopies.has(found.real)) cordisCopies.set(found.real, { version: found.version, owners: [] })
169
+ cordisCopies.get(found.real).owners.push(label)
170
+ }
171
+
172
+ for (const bundle of bundles) {
173
+ const found = packageIn(profile, bundle)
174
+ if (found === undefined) {
175
+ items.push(bundle.startsWith('@deepseek-ai/')
176
+ ? { name: bundle, client: false, status: 'ok', code: 'provided', detail: '', hint: null }
177
+ : { name: bundle, client: false, status: 'error', code: 'not-installed', detail: '', hint: 'add' })
178
+ continue
179
+ }
180
+ const problems = []
181
+ const entry = subpathEntry(found.dir, found.pkg, '.')
182
+ if (entry === undefined) problems.push({ code: 'entry-undeclared', detail: '', status: 'warn' })
183
+ else if (!existsSync(entry)) problems.push({ code: 'entry-missing', detail: entry, status: 'error' })
184
+
185
+ const patch = dshBlock(found.pkg).bundle?.patch
186
+ if (typeof patch === 'string' && !existsSync(join(found.dir, patch))) {
187
+ problems.push({ code: 'patch-missing', detail: patch, status: 'error' })
188
+ }
189
+
190
+ const declaresClient = dshBlock(found.pkg).client !== undefined
191
+ if (declaresClient) {
192
+ const clientFile = subpathEntry(found.dir, found.pkg, './client')
193
+ if (clientFile === undefined) problems.push({ code: 'client-undeclared', detail: '', status: 'warn' })
194
+ else if (!existsSync(clientFile)) problems.push({ code: 'client-missing', detail: clientFile, status: 'error' })
195
+ }
196
+ rememberCordis(bundle, found.dir)
197
+ items.push({
198
+ name: bundle,
199
+ client: declaresClient,
200
+ version: found.pkg.version,
201
+ status: worst(problems.map((problem) => ({ status: problem.status }))),
202
+ code: problems.length === 0 ? null : problems.map((problem) => problem.code).join('+'),
203
+ detail: problems.length === 0
204
+ ? `v${found.pkg.version}${found.linked ? ' (linked)' : ''}`
205
+ : problems.map((problem) => problem.detail).filter((text) => text.length > 0).join('; '),
206
+ hint: problems.length === 0 ? null : 'reinstall',
207
+ })
208
+ }
209
+ checks.push({
210
+ id: 'plugins',
211
+ status: worst(items),
212
+ data: { total: items.length, ok: items.filter((item) => item.status === 'ok').length },
213
+ items,
214
+ })
215
+
216
+ const deps = Object.entries(profile.pkg.dependencies ?? {}).map(([dep, range]) => {
217
+ const found = packageIn(profile, dep)
218
+ if (found === undefined) {
219
+ return { name: dep, status: 'error', code: 'not-installed', detail: String(range), hint: 'install' }
220
+ }
221
+ return { name: dep, status: 'ok', code: null, detail: `v${found.pkg.version}${found.linked ? ' (linked)' : ''}` }
222
+ })
223
+ checks.push({ id: 'deps', status: worst(deps), data: { count: deps.length }, items: deps })
224
+
225
+ rememberCordis(profile.name, profile.dir)
226
+ const cordisItems = [...cordisCopies.entries()].map(([file, record]) => ({
227
+ name: record.owners.length === 2 && record.owners[0] === profile.name ? record.owners[1] : record.owners.join(', '),
228
+ status: 'ok',
229
+ code: null,
230
+ detail: `v${record.version} · ${dirname(dirname(file))}`,
231
+ }))
232
+ const cordisVersions = [...new Set([...cordisCopies.values()].map((record) => record.version))]
233
+ const cordisStatus = cordisVersions.length > 1 ? 'error' : cordisCopies.size > 1 ? 'warn' : 'ok'
234
+ checks.push({
235
+ id: 'cordis',
236
+ status: cordisStatus,
237
+ data: { copies: cordisCopies.size, versions: cordisVersions.length },
238
+ items: cordisItems,
239
+ hint: cordisStatus === 'ok' ? null : (cordisVersions.length > 1 ? 'cordis-mixed' : 'cordis-duplicate'),
240
+ })
241
+
242
+ const harness = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
243
+ const versionItems = harness.map((dep) => {
244
+ const installed = installedVersion(dep)
245
+ if (installed === undefined) return { name: dep, status: 'warn', code: 'unresolved', detail: '', hint: null }
246
+ const same = typeof commandVersion !== 'string' || commandVersion.length === 0 || compareVersions(installed, commandVersion) === 0
247
+ return {
248
+ name: dep,
249
+ status: same ? 'ok' : 'warn',
250
+ code: same ? null : 'service-mismatch',
251
+ detail: `v${installed}`,
252
+ hint: same ? null : 'service-version',
253
+ }
254
+ })
255
+ const own = installedVersion(name)
256
+ versionItems.push({
257
+ name,
258
+ status: own === undefined ? 'warn' : 'ok',
259
+ code: own === undefined ? 'unresolved' : null,
260
+ detail: own === undefined ? '' : `v${own}`,
261
+ })
262
+ const versionHint = worst(versionItems) === 'ok' ? null : 'service-version'
263
+ checks.push({
264
+ id: 'versions',
265
+ status: worst(versionItems),
266
+ data: { commandVersion: typeof commandVersion === 'string' ? commandVersion : null },
267
+ items: versionItems,
268
+ hint: versionHint,
269
+ })
270
+
271
+ return {
272
+ // `ok` means "nothing broken": a warning is information, not a failure.
273
+ ok: checks.every((check) => check.status !== 'error'),
274
+ profile: { name: profile.name, dir: profile.dir },
275
+ checks,
276
+ }
277
+ }
package/lib/index.js CHANGED
@@ -11,8 +11,9 @@
11
11
  * `pricing.js`), so each bucket carries an estimated amount alongside tokens.
12
12
  *
13
13
  * Queries: `GET /dsh-token-use` returns all-time buckets;
14
- * `?day=YYYY-MM-DD` and `?month=YYYY-MM` return the matching window,
15
- * aggregated in memory from per-day buckets (no rescan, no disk).
14
+ * `?day=YYYY-MM-DD`, `?month=YYYY-MM`, `?year=YYYY` and
15
+ * `?from=YYYY-MM-DD&to=YYYY-MM-DD` return the matching window, aggregated in
16
+ * memory from per-day buckets (no rescan, no disk).
16
17
  *
17
18
  * @module dsh-token-use
18
19
  */
@@ -23,6 +24,8 @@ import { join } from 'node:path'
23
24
  import { zstdDecompressSync } from 'node:zlib'
24
25
  import { scheduler } from 'node:timers/promises'
25
26
  import { PricingStore } from './pricing.js'
27
+ import { VersionStore, installedVersion, restartScript, scheduleRestart } from './versions.js'
28
+ import { checkHarness } from './health.js'
26
29
 
27
30
  export const name = 'dsh-token-use'
28
31
 
@@ -40,11 +43,11 @@ const USAGE_FIELDS = [
40
43
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
41
44
 
42
45
  /**
43
- * Days of per-day buckets the snapshot carries for the trend chart. The client
44
- * slices this down to its 7/30/90-day range switcher, so the widest range is
45
- * served without another round trip.
46
+ * Days of per-day buckets the snapshot carries. The client slices this down to
47
+ * its 7/30/90-day trend switcher and draws the 12-month heatmap from the same
48
+ * series, so neither needs a second round trip.
46
49
  */
47
- const TREND_DAYS = 90
50
+ const TREND_DAYS = 366
48
51
 
49
52
  /**
50
53
  * Auxiliary LLM calls that DeepSeek bills but that persist no `usage` record:
@@ -83,6 +86,7 @@ function pickSessionLog(entries) {
83
86
  const ZSTD_MAGIC = 4247762216
84
87
  const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
85
88
  const MONTH_RE = /^\d{4}-\d{2}$/
89
+ const YEAR_RE = /^\d{4}$/
86
90
 
87
91
  function resolveDshHome(env = process.env) {
88
92
  const explicit = env.DSH_HOME
@@ -251,6 +255,7 @@ export class UsageTracker {
251
255
  constructor(dshHome = resolveDshHome(), options = {}) {
252
256
  this.dshHome = dshHome
253
257
  this.allowRemote = options.allowRemote === true
258
+ this.endpoint = typeof options.endpoint === 'string' && options.endpoint.length > 0 ? options.endpoint : '/dsh-token-use'
254
259
  this.pricing = options.pricing ?? new PricingStore(dshHome, options.pricingOptions ?? {})
255
260
  this.startedAt = Date.now()
256
261
  this.totals = emptyBucket()
@@ -619,6 +624,16 @@ export class UsageTracker {
619
624
  return [...this.byModel.keys()].sort()
620
625
  }
621
626
 
627
+ /**
628
+ * Years that own at least one day bucket, oldest first: the year picker lists
629
+ * these instead of asking for a free-text year that may hold no data.
630
+ */
631
+ years() {
632
+ const years = new Set()
633
+ for (const day of this.byDay.keys()) years.add(day.slice(0, 4))
634
+ return [...years].sort()
635
+ }
636
+
622
637
  /**
623
638
  * Materialize totals/breakdowns for the optional window + model filter.
624
639
  * Filtering only re-aggregates existing per-day buckets — no rescan, no disk.
@@ -646,7 +661,10 @@ export class UsageTracker {
646
661
  const allDays = [...new Set([...this.byDay.keys(), ...this.dayAuxiliary.keys()])].sort()
647
662
  const days = filter.kind === 'all'
648
663
  ? allDays
649
- : allDays.filter((day) => (filter.kind === 'day' ? day === filter.value : day.startsWith(filter.value)))
664
+ : filter.kind === 'range'
665
+ ? allDays.filter((day) => day >= filter.from && day <= filter.to)
666
+ // day is an exact match; month (YYYY-MM) and year (YYYY) are prefixes.
667
+ : allDays.filter((day) => (filter.kind === 'day' ? day === filter.value : day.startsWith(filter.value)))
650
668
  const totals = emptyBucket()
651
669
  const models = new Map()
652
670
  const projects = new Map()
@@ -668,7 +686,7 @@ export class UsageTracker {
668
686
  const lastDataDay = days.length > 0 ? days[days.length - 1] : undefined
669
687
  const trendEnd = filter.kind === 'day'
670
688
  ? filter.value
671
- : (lastDataDay ?? localDay(Date.now()))
689
+ : (lastDataDay ?? (filter.kind === 'range' ? filter.to : localDay(Date.now())))
672
690
  const trendStart = shiftDay(trendEnd, -(TREND_DAYS - 1))
673
691
  for (let day = trendStart; day <= trendEnd; day = shiftDay(day, 1)) {
674
692
  const bucket = model === undefined ? this.byDay.get(day) : this.dayModel.get(day)?.get(model)
@@ -704,8 +722,15 @@ export class UsageTracker {
704
722
  startedAt: this.startedAt,
705
723
  updatedAt: now,
706
724
  dshHome: this.dshHome,
707
- filter: filter === null ? null : { kind: filter.kind, value: filter.value ?? null, model: filter.model ?? null },
725
+ filter: filter === null ? null : {
726
+ kind: filter.kind,
727
+ value: filter.value ?? null,
728
+ from: filter.from ?? null,
729
+ to: filter.to ?? null,
730
+ model: filter.model ?? null,
731
+ },
708
732
  models: this.models(),
733
+ years: this.years(),
709
734
  modelPricing,
710
735
  pricing: this.pricing.describe(now),
711
736
  auxiliary: view.auxiliary,
@@ -722,6 +747,122 @@ export class UsageTracker {
722
747
  }
723
748
  }
724
749
 
750
+ /** Loopback, or a same-origin request when `allowRemote` was opted into. */
751
+ trusted(req) {
752
+ if (LOOPBACK.has(req.socket?.remoteAddress)) return true
753
+ const origin = req.headers.origin
754
+ const sameOrigin = req.headers['sec-fetch-site'] === 'same-origin'
755
+ && typeof origin === 'string'
756
+ && typeof req.headers.host === 'string'
757
+ let trusted = false
758
+ if (sameOrigin) {
759
+ try {
760
+ trusted = new URL(origin).host === req.headers.host
761
+ } catch {
762
+ trusted = false
763
+ }
764
+ }
765
+ return this.allowRemote && trusted
766
+ }
767
+
768
+ deny(res) {
769
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
770
+ res.end(JSON.stringify({ ok: false, error: 'loopback only; set allowRemote for trusted same-origin access' }))
771
+ }
772
+
773
+ /**
774
+ * Config tab payload: installed vs latest versions for the harness packages,
775
+ * the runtime facts, and the restart capability of this launch command.
776
+ */
777
+ async handleConfig(req, res, versions) {
778
+ res.setHeader('cache-control', 'no-store')
779
+ if (req.method !== 'GET') {
780
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
781
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
782
+ return
783
+ }
784
+ if (!this.trusted(req)) {
785
+ this.deny(res)
786
+ return
787
+ }
788
+ const host = req.headers.host
789
+ const port = typeof host === 'string' && host.includes(':') ? host.slice(host.lastIndexOf(':') + 1) : null
790
+ // `?refresh=1` is the panel's re-check button: it skips the six-hour cache.
791
+ const force = new URL(req.url, 'http://localhost').searchParams.get('refresh') === '1'
792
+ const report = await versions.describe({ port, dshHome: this.dshHome, force })
793
+ const planned = restartScript({ pid: process.pid, argv: process.argv, cwd: process.cwd(), logFile: 'probe' })
794
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
795
+ res.end(JSON.stringify({
796
+ ok: true,
797
+ name,
798
+ version: VERSION,
799
+ endpoint: this.endpoint,
800
+ restartable: planned !== undefined,
801
+ ...report,
802
+ }))
803
+ }
804
+
805
+ /**
806
+ * Harness self-check: what the browser would load, plugin by plugin, plus the
807
+ * profile's own integrity — the failures that leave a plugin installed but
808
+ * never running.
809
+ */
810
+ async handleHealth(req, res) {
811
+ res.setHeader('cache-control', 'no-store')
812
+ if (req.method !== 'GET') {
813
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
814
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
815
+ return
816
+ }
817
+ if (!this.trusted(req)) {
818
+ this.deny(res)
819
+ return
820
+ }
821
+ const host = req.headers.host
822
+ const port = typeof host === 'string' && host.includes(':') ? host.slice(host.lastIndexOf(':') + 1) : null
823
+ let report
824
+ try {
825
+ report = checkHarness({
826
+ dshHome: this.dshHome,
827
+ name,
828
+ commandVersion: installedVersion('@deepseek-ai/dsh'),
829
+ })
830
+ } catch (error) {
831
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
832
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }))
833
+ return
834
+ }
835
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
836
+ res.end(JSON.stringify({ ok: true, ...report }))
837
+ }
838
+
839
+ /** One-click restart: schedule the detached script and answer immediately. */
840
+ handleRestart(req, res) {
841
+ res.setHeader('cache-control', 'no-store')
842
+ if (req.method !== 'POST') {
843
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
844
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
845
+ return
846
+ }
847
+ if (!this.trusted(req)) {
848
+ this.deny(res)
849
+ return
850
+ }
851
+ const scheduled = scheduleRestart(this.dshHome)
852
+ if (scheduled === undefined) {
853
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
854
+ res.end(JSON.stringify({ ok: false, error: 'this launch command cannot be restarted automatically' }))
855
+ return
856
+ }
857
+ if (scheduled.error !== undefined) {
858
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
859
+ res.end(JSON.stringify({ ok: false, error: scheduled.error }))
860
+ return
861
+ }
862
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
863
+ res.end(JSON.stringify({ ok: true, delaySec: 2, logFile: scheduled.logFile, script: scheduled.script }))
864
+ }
865
+
725
866
  handle(req, res) {
726
867
  res.setHeader('cache-control', 'no-store')
727
868
  if (req.method !== 'GET') {
@@ -729,50 +870,59 @@ export class UsageTracker {
729
870
  res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
730
871
  return
731
872
  }
732
- if (!LOOPBACK.has(req.socket?.remoteAddress)) {
733
- const origin = req.headers.origin
734
- const sameOrigin = req.headers['sec-fetch-site'] === 'same-origin'
735
- && typeof origin === 'string'
736
- && typeof req.headers.host === 'string'
737
- let trusted = false
738
- if (sameOrigin) {
739
- try {
740
- trusted = new URL(origin).host === req.headers.host
741
- } catch {
742
- trusted = false
743
- }
744
- }
745
- if (!(this.allowRemote && trusted)) {
746
- res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
747
- res.end(JSON.stringify({ ok: false, error: 'loopback only; set allowRemote for trusted same-origin access' }))
748
- return
749
- }
873
+ if (!this.trusted(req)) {
874
+ this.deny(res)
875
+ return
750
876
  }
751
877
  const params = new URL(req.url, 'http://localhost').searchParams
752
878
  const day = params.get('day')
753
879
  const month = params.get('month')
880
+ const year = params.get('year')
881
+ const from = params.get('from')
882
+ const to = params.get('to')
754
883
  const rawModel = params.get('model')
755
884
  const model = rawModel !== null && rawModel.length > 0 && rawModel.length <= 200 ? rawModel : undefined
756
- let filter = null
757
- if (day !== null && month !== null) {
885
+ const reject = (error) => {
758
886
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
759
- res.end(JSON.stringify({ ok: false, error: 'use exactly one of day or month' }))
887
+ res.end(JSON.stringify({ ok: false, error }))
888
+ }
889
+ let filter = null
890
+ if ((from === null) !== (to === null)) {
891
+ reject('from and to must be used together')
892
+ return
893
+ }
894
+ if ([day !== null, month !== null, year !== null, from !== null].filter(Boolean).length > 1) {
895
+ reject('use exactly one of day, month, year or from+to')
760
896
  return
761
897
  }
762
898
  if (day !== null) {
763
899
  if (!DAY_RE.test(day)) {
764
- res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
765
- res.end(JSON.stringify({ ok: false, error: 'day must be YYYY-MM-DD' }))
900
+ reject('day must be YYYY-MM-DD')
766
901
  return
767
902
  }
768
903
  filter = { kind: 'day', value: day, model }
769
904
  } else if (month !== null) {
770
905
  if (!MONTH_RE.test(month)) {
771
- res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
772
- res.end(JSON.stringify({ ok: false, error: 'month must be YYYY-MM' }))
906
+ reject('month must be YYYY-MM')
773
907
  return
774
908
  }
775
909
  filter = { kind: 'month', value: month, model }
910
+ } else if (year !== null) {
911
+ if (!YEAR_RE.test(year)) {
912
+ reject('year must be YYYY')
913
+ return
914
+ }
915
+ filter = { kind: 'year', value: year, model }
916
+ } else if (from !== null) {
917
+ if (!DAY_RE.test(from) || !DAY_RE.test(to)) {
918
+ reject('from and to must be YYYY-MM-DD')
919
+ return
920
+ }
921
+ if (from > to) {
922
+ reject('from must not be after to')
923
+ return
924
+ }
925
+ filter = { kind: 'range', from, to, model }
776
926
  } else if (model !== undefined) {
777
927
  filter = { kind: 'all', value: undefined, model }
778
928
  }
@@ -784,8 +934,9 @@ export class UsageTracker {
784
934
  /**
785
935
  * Register the tracker once the profile's webServer service exists.
786
936
  * @param ctx - host context from the cordis loader.
787
- * @param config - loader config: `endpoint`, `scanAtBoot`, `allowRemote` and
788
- * `pricing` (`enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`).
937
+ * @param config - loader config: `endpoint`, `scanAtBoot`, `allowRemote`,
938
+ * `pricing` (`enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`) and
939
+ * `versions` (`registry`, `timeoutMs`, `ttlMs`).
789
940
  */
790
941
  export function apply(ctx, config = {}) {
791
942
  const endpoint = typeof config.endpoint === 'string' && config.endpoint.length > 0 ? config.endpoint : '/dsh-token-use'
@@ -793,7 +944,8 @@ export function apply(ctx, config = {}) {
793
944
  ctx.inject(['webServer'], (host) => {
794
945
  const dshHome = resolveDshHome()
795
946
  const pricing = new PricingStore(dshHome, config.pricing ?? {})
796
- const tracker = new UsageTracker(dshHome, { allowRemote: config.allowRemote === true, pricing })
947
+ const versions = new VersionStore(dshHome, config.versions ?? {})
948
+ const tracker = new UsageTracker(dshHome, { allowRemote: config.allowRemote === true, pricing, endpoint })
797
949
  host.effect(() => {
798
950
  pricing.start()
799
951
  return () => pricing.stop()
@@ -802,13 +954,17 @@ export function apply(ctx, config = {}) {
802
954
  tracker.fold(session, event)
803
955
  }, { global: true })
804
956
  host.effect(() => {
805
- const dispose = host.webServer.register({
806
- kind: 'exact',
807
- path: endpoint,
808
- handler: (req, res) => tracker.handle(req, res),
809
- })
810
- return () => dispose()
811
- }, 'dsh-token-use: http route')
957
+ const routes = [
958
+ { path: endpoint, handler: (req, res) => tracker.handle(req, res) },
959
+ { path: `${endpoint}/config`, handler: (req, res) => tracker.handleConfig(req, res, versions) },
960
+ { path: `${endpoint}/health`, handler: (req, res) => tracker.handleHealth(req, res) },
961
+ { path: `${endpoint}/restart`, handler: (req, res) => tracker.handleRestart(req, res) },
962
+ ]
963
+ const disposers = routes.map((route) => host.webServer.register({ kind: 'exact', ...route }))
964
+ return () => {
965
+ for (const dispose of disposers) dispose()
966
+ }
967
+ }, 'dsh-token-use: http routes')
812
968
  if (scanAtBoot) {
813
969
  tracker.scanSessions().catch((error) => {
814
970
  tracker.scan.error = String(error?.stack ?? error)