@roastcodes/ttdash 6.1.7 → 6.1.9

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/server.js CHANGED
@@ -6,10 +6,18 @@ const os = require('os');
6
6
  const path = require('path');
7
7
  const readline = require('readline/promises');
8
8
  const { spawn } = require('child_process');
9
+ const spawnCrossPlatform = require('cross-spawn');
9
10
  const { parseArgs } = require('util');
10
11
  const { normalizeIncomingData } = require('./usage-normalizer');
11
12
  const { generatePdfReport } = require('./server/report');
12
13
  const { version: APP_VERSION } = require('./package.json');
14
+ const dashboardPreferences = require('./shared/dashboard-preferences.json');
15
+ const { createHttpUtils } = require('./server/http-utils');
16
+ const {
17
+ ensureBindHostAllowed,
18
+ isLoopbackHost,
19
+ listenOnAvailablePort,
20
+ } = require('./server/runtime');
13
21
 
14
22
  const ROOT = __dirname;
15
23
  const STATIC_ROOT = path.join(ROOT, 'dist');
@@ -23,9 +31,12 @@ const ENV_START_PORT = parseInt(process.env.PORT, 10);
23
31
  const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000);
24
32
  const MAX_PORT = Math.min(START_PORT + 100, 65535);
25
33
  const BIND_HOST = process.env.HOST || '127.0.0.1';
26
- const API_PREFIX = '/port/5000/api';
34
+ const ALLOW_REMOTE_BIND = process.env.TTDASH_ALLOW_REMOTE === '1';
35
+ const API_PREFIX = process.env.API_PREFIX || '/api';
27
36
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
28
37
  const IS_WINDOWS = process.platform === 'win32';
38
+ const SECURE_DIR_MODE = 0o700;
39
+ const SECURE_FILE_MODE = 0o600;
29
40
  const TOKTRACK_LOCAL_BIN = path.join(
30
41
  ROOT,
31
42
  'node_modules',
@@ -46,22 +57,8 @@ const USAGE_BACKUP_KIND = 'ttdash-usage-backup';
46
57
  const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1';
47
58
  const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1';
48
59
  const BACKGROUND_START_TIMEOUT_MS = 15000;
49
- const DASHBOARD_DATE_PRESETS = ['all', '7d', '30d', 'month', 'year'];
50
- const DASHBOARD_SECTION_IDS = [
51
- 'insights',
52
- 'metrics',
53
- 'today',
54
- 'currentMonth',
55
- 'activity',
56
- 'forecastCache',
57
- 'limits',
58
- 'costAnalysis',
59
- 'tokenAnalysis',
60
- 'requestAnalysis',
61
- 'advancedAnalysis',
62
- 'comparisons',
63
- 'tables',
64
- ];
60
+ const DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets;
61
+ const DASHBOARD_SECTION_IDS = dashboardPreferences.sectionDefinitions.map((section) => section.id);
65
62
  const DEFAULT_SETTINGS = {
66
63
  language: 'de',
67
64
  theme: 'dark',
@@ -129,6 +126,7 @@ function printHelp() {
129
126
  console.log(' PORT=3010 ttdash');
130
127
  console.log(' NO_OPEN_BROWSER=1 ttdash');
131
128
  console.log(' HOST=127.0.0.1 ttdash');
129
+ console.log(' TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash');
132
130
  }
133
131
 
134
132
  function parseCliArgs(rawArgs) {
@@ -290,7 +288,10 @@ const MIME_TYPES = {
290
288
  };
291
289
 
292
290
  function ensureDir(dirPath) {
293
- fs.mkdirSync(dirPath, { recursive: true });
291
+ fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE });
292
+ if (!IS_WINDOWS) {
293
+ fs.chmodSync(dirPath, SECURE_DIR_MODE);
294
+ }
294
295
  }
295
296
 
296
297
  function ensureAppDirs() {
@@ -304,7 +305,12 @@ function ensureAppDirs() {
304
305
  function writeJsonAtomic(filePath, data) {
305
306
  ensureDir(path.dirname(filePath));
306
307
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
307
- fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
308
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), {
309
+ mode: SECURE_FILE_MODE,
310
+ });
311
+ if (!IS_WINDOWS) {
312
+ fs.chmodSync(tempPath, SECURE_FILE_MODE);
313
+ }
308
314
  fs.renameSync(tempPath, filePath);
309
315
  }
310
316
 
@@ -337,11 +343,12 @@ async function fetchRuntimeIdentity(url, timeoutMs = 1000) {
337
343
  return null;
338
344
  }
339
345
 
346
+ const runtimePath = `${API_PREFIX.replace(/\/+$/, '')}/runtime`;
340
347
  const controller = new AbortController();
341
348
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
342
349
 
343
350
  try {
344
- const response = await fetch(new URL('/api/runtime', `${url}/`), {
351
+ const response = await fetch(new URL(runtimePath, `${url}/`), {
345
352
  signal: controller.signal,
346
353
  });
347
354
 
@@ -473,7 +480,7 @@ async function withBackgroundInstancesLock(
473
480
 
474
481
  while (true) {
475
482
  try {
476
- fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR);
483
+ fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR, { mode: SECURE_DIR_MODE });
477
484
  break;
478
485
  } catch (error) {
479
486
  if (!error || error.code !== 'EEXIST') {
@@ -753,11 +760,15 @@ function shouldBackgroundChildOpenBrowser() {
753
760
  }
754
761
 
755
762
  async function startInBackground() {
763
+ ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND);
756
764
  ensureAppDirs();
757
765
 
758
766
  const logFile = buildBackgroundLogFilePath();
759
767
  const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background');
760
- const logFd = fs.openSync(logFile, 'a');
768
+ const logFd = fs.openSync(logFile, 'a', SECURE_FILE_MODE);
769
+ if (!IS_WINDOWS) {
770
+ fs.fchmodSync(logFd, SECURE_FILE_MODE);
771
+ }
761
772
 
762
773
  let child;
763
774
  try {
@@ -846,6 +857,46 @@ function normalizeIsoTimestamp(value) {
846
857
  return new Date(timestamp).toISOString();
847
858
  }
848
859
 
860
+ function createPersistedStateError(kind, filePath, cause) {
861
+ const label = kind === 'settings' ? 'Settings file' : 'Usage data file';
862
+ const error = new Error(`${label} is unreadable or corrupted.`);
863
+ error.code = 'PERSISTED_STATE_INVALID';
864
+ error.kind = kind;
865
+ error.filePath = filePath;
866
+ error.cause = cause;
867
+ return error;
868
+ }
869
+
870
+ function isPersistedStateError(error, kind) {
871
+ return (
872
+ Boolean(error) &&
873
+ error.code === 'PERSISTED_STATE_INVALID' &&
874
+ (kind ? error.kind === kind : true)
875
+ );
876
+ }
877
+
878
+ function isPayloadTooLargeError(error) {
879
+ return Boolean(error) && error.code === 'PAYLOAD_TOO_LARGE';
880
+ }
881
+
882
+ function readJsonFile(filePath, kind) {
883
+ try {
884
+ return {
885
+ status: 'ok',
886
+ value: JSON.parse(fs.readFileSync(filePath, 'utf-8')),
887
+ };
888
+ } catch (error) {
889
+ if (error && error.code === 'ENOENT') {
890
+ return {
891
+ status: 'missing',
892
+ value: null,
893
+ };
894
+ }
895
+
896
+ throw createPersistedStateError(kind, filePath, error);
897
+ }
898
+ }
899
+
849
900
  function sanitizeCurrency(value) {
850
901
  if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
851
902
  return Math.max(0, Number(value.toFixed(2)));
@@ -855,6 +906,49 @@ function isPlainObject(value) {
855
906
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
856
907
  }
857
908
 
909
+ function createAutoImportMessageEvent(key, vars = {}) {
910
+ return {
911
+ key,
912
+ vars,
913
+ };
914
+ }
915
+
916
+ function createAutoImportError(message, key, vars = {}) {
917
+ const error = new Error(message);
918
+ error.messageKey = key;
919
+ error.messageVars = vars;
920
+ return error;
921
+ }
922
+
923
+ function toAutoImportErrorEvent(error) {
924
+ if (error && typeof error.messageKey === 'string') {
925
+ return createAutoImportMessageEvent(error.messageKey, error.messageVars || {});
926
+ }
927
+
928
+ return createAutoImportMessageEvent('errorPrefix', {
929
+ message: error && error.message ? error.message : 'Unknown error',
930
+ });
931
+ }
932
+
933
+ function formatAutoImportMessageEvent(event) {
934
+ switch (event?.key) {
935
+ case 'startingLocalImport':
936
+ return 'Starting local toktrack import...';
937
+ case 'loadingUsageData':
938
+ return `Loading usage data via ${event.vars?.command || 'unknown command'}...`;
939
+ case 'processingUsageData':
940
+ return `Processing usage data... (${event.vars?.seconds || 0}s)`;
941
+ case 'autoImportRunning':
942
+ return 'An auto-import is already running. Please wait.';
943
+ case 'noRunnerFound':
944
+ return 'No local toktrack, Bun, or npm exec installation found.';
945
+ case 'errorPrefix':
946
+ return `Error: ${event.vars?.message || 'Unknown error'}`;
947
+ default:
948
+ return 'Auto-import update';
949
+ }
950
+ }
951
+
858
952
  function computeUsageTotals(daily) {
859
953
  return daily.reduce(
860
954
  (totals, day) => ({
@@ -1191,6 +1285,7 @@ function printStartupSummary(url, port) {
1191
1285
  const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled';
1192
1286
  const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled';
1193
1287
  const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground';
1288
+ const remoteBind = !isLoopbackHost(BIND_HOST);
1194
1289
 
1195
1290
  console.log('');
1196
1291
  console.log(`${APP_LABEL} v${APP_VERSION} is ready`);
@@ -1198,6 +1293,9 @@ function printStartupSummary(url, port) {
1198
1293
  console.log(` API: ${url}/api/usage`);
1199
1294
  console.log(` Port: ${port}`);
1200
1295
  console.log(` Host: ${BIND_HOST}`);
1296
+ if (remoteBind) {
1297
+ console.log(` Exposure: network-accessible via ${BIND_HOST}`);
1298
+ }
1201
1299
  console.log(` Mode: ${runtimeMode}`);
1202
1300
  console.log(` Static Root: ${STATIC_ROOT}`);
1203
1301
  console.log(` Data File: ${DATA_FILE}`);
@@ -1208,6 +1306,13 @@ function printStartupSummary(url, port) {
1208
1306
  console.log(` Data Status: ${describeDataFile()}`);
1209
1307
  console.log(` Browser Open: ${browserMode}`);
1210
1308
  console.log(` Auto-Load: ${autoLoadMode}`);
1309
+ if (remoteBind) {
1310
+ console.log('');
1311
+ console.log(
1312
+ 'Security warning: this bind host can expose local data and destructive API routes.',
1313
+ );
1314
+ console.log('Use non-loopback hosts only on trusted networks.');
1315
+ }
1211
1316
  console.log('');
1212
1317
  console.log('Available ways to load data:');
1213
1318
  console.log(' 1. Start auto-import from the app');
@@ -1219,6 +1324,7 @@ function printStartupSummary(url, port) {
1219
1324
  console.log(' ttdash --background');
1220
1325
  console.log(' ttdash stop');
1221
1326
  console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`);
1327
+ console.log(` TTDASH_ALLOW_REMOTE=1 HOST=${BIND_HOST} PORT=${port} node server.js`);
1222
1328
  console.log(` curl ${url}/api/usage`);
1223
1329
  console.log('');
1224
1330
  }
@@ -1271,11 +1377,16 @@ function serveFile(res, reqPath) {
1271
1377
  // --- API helpers ---
1272
1378
 
1273
1379
  function readData() {
1274
- try {
1275
- return normalizeIncomingData(JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8')));
1276
- } catch {
1380
+ const file = readJsonFile(DATA_FILE, 'usage');
1381
+ if (file.status === 'missing') {
1277
1382
  return null;
1278
1383
  }
1384
+
1385
+ try {
1386
+ return normalizeIncomingData(file.value);
1387
+ } catch (error) {
1388
+ throw createPersistedStateError('usage', DATA_FILE, error);
1389
+ }
1279
1390
  }
1280
1391
 
1281
1392
  function writeData(data) {
@@ -1283,14 +1394,30 @@ function writeData(data) {
1283
1394
  }
1284
1395
 
1285
1396
  function readSettings() {
1286
- try {
1287
- return toSettingsResponse(JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')));
1288
- } catch {
1397
+ const file = readJsonFile(SETTINGS_FILE, 'settings');
1398
+ if (file.status === 'missing') {
1289
1399
  return toSettingsResponse({
1290
1400
  ...DEFAULT_SETTINGS,
1291
1401
  providerLimits: {},
1292
1402
  });
1293
1403
  }
1404
+
1405
+ return toSettingsResponse(file.value);
1406
+ }
1407
+
1408
+ function readSettingsForWrite() {
1409
+ try {
1410
+ return readSettings();
1411
+ } catch (error) {
1412
+ if (isPersistedStateError(error, 'settings')) {
1413
+ return toSettingsResponse({
1414
+ ...DEFAULT_SETTINGS,
1415
+ providerLimits: {},
1416
+ });
1417
+ }
1418
+
1419
+ throw error;
1420
+ }
1294
1421
  }
1295
1422
 
1296
1423
  function writeSettings(settings) {
@@ -1298,7 +1425,7 @@ function writeSettings(settings) {
1298
1425
  }
1299
1426
 
1300
1427
  function updateSettings(patch) {
1301
- const current = readSettings();
1428
+ const current = readSettingsForWrite();
1302
1429
  const next = {
1303
1430
  ...current,
1304
1431
  ...(patch && typeof patch === 'object' ? patch : {}),
@@ -1318,7 +1445,7 @@ function updateSettings(patch) {
1318
1445
  }
1319
1446
 
1320
1447
  function recordDataLoad(source) {
1321
- const current = readSettings();
1448
+ const current = readSettingsForWrite();
1322
1449
  const next = {
1323
1450
  ...current,
1324
1451
  lastLoadedAt: new Date().toISOString(),
@@ -1330,7 +1457,7 @@ function recordDataLoad(source) {
1330
1457
  }
1331
1458
 
1332
1459
  function clearDataLoadState() {
1333
- const current = readSettings();
1460
+ const current = readSettingsForWrite();
1334
1461
  const next = {
1335
1462
  ...current,
1336
1463
  lastLoadedAt: null,
@@ -1340,63 +1467,11 @@ function clearDataLoadState() {
1340
1467
  writeSettings(next);
1341
1468
  return toSettingsResponse(next);
1342
1469
  }
1343
-
1344
- function readBody(req) {
1345
- return new Promise((resolve, reject) => {
1346
- const chunks = [];
1347
- let totalSize = 0;
1348
- req.on('data', (c) => {
1349
- totalSize += c.length;
1350
- if (totalSize > MAX_BODY_SIZE) {
1351
- req.destroy();
1352
- reject(new Error('Payload too large'));
1353
- return;
1354
- }
1355
- chunks.push(c);
1356
- });
1357
- req.on('end', () => {
1358
- try {
1359
- resolve(JSON.parse(Buffer.concat(chunks).toString()));
1360
- } catch (e) {
1361
- reject(e);
1362
- }
1363
- });
1364
- req.on('error', reject);
1365
- });
1366
- }
1367
-
1368
- function json(res, status, data) {
1369
- res.writeHead(status, {
1370
- 'Content-Type': 'application/json; charset=utf-8',
1371
- ...SECURITY_HEADERS,
1372
- });
1373
- res.end(JSON.stringify(data));
1374
- }
1375
-
1376
- function sendBuffer(res, status, headers, buffer) {
1377
- res.writeHead(status, {
1378
- 'Content-Length': buffer.length,
1379
- ...headers,
1380
- ...SECURITY_HEADERS,
1381
- });
1382
- res.end(buffer);
1383
- }
1384
-
1385
- function resolveApiPath(pathname) {
1386
- if (pathname.startsWith(API_PREFIX + '/')) {
1387
- return pathname.slice(API_PREFIX.length);
1388
- }
1389
- if (pathname === API_PREFIX) {
1390
- return '/';
1391
- }
1392
- if (pathname.startsWith('/api/')) {
1393
- return pathname.slice(4);
1394
- }
1395
- if (pathname === '/api') {
1396
- return '/';
1397
- }
1398
- return null;
1399
- }
1470
+ const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({
1471
+ apiPrefix: API_PREFIX,
1472
+ maxBodySize: MAX_BODY_SIZE,
1473
+ securityHeaders: SECURITY_HEADERS,
1474
+ });
1400
1475
 
1401
1476
  // --- SSE helpers ---
1402
1477
 
@@ -1406,10 +1481,6 @@ function sendSSE(res, event, data) {
1406
1481
 
1407
1482
  let autoImportRunning = false;
1408
1483
 
1409
- function shouldUseShell(command) {
1410
- return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
1411
- }
1412
-
1413
1484
  function getExecutableName(baseName, isWindows = IS_WINDOWS) {
1414
1485
  if (!isWindows) {
1415
1486
  return baseName;
@@ -1427,9 +1498,10 @@ function getExecutableName(baseName, isWindows = IS_WINDOWS) {
1427
1498
  }
1428
1499
 
1429
1500
  function spawnCommand(command, args, options = {}) {
1430
- return spawn(command, args, {
1501
+ // cross-spawn resolves Windows command shims without relying on shell=true,
1502
+ // which avoids the DEP0190 warning from Node's child_process APIs.
1503
+ return spawnCrossPlatform(command, args, {
1431
1504
  ...options,
1432
- shell: options.shell ?? shouldUseShell(command),
1433
1505
  windowsHide: options.windowsHide ?? true,
1434
1506
  });
1435
1507
  }
@@ -1527,24 +1599,30 @@ async function performAutoImport({
1527
1599
  signalOnClose,
1528
1600
  } = {}) {
1529
1601
  if (autoImportRunning) {
1530
- throw new Error('An auto-import is already running. Please wait.');
1602
+ throw createAutoImportError(
1603
+ 'An auto-import is already running. Please wait.',
1604
+ 'autoImportRunning',
1605
+ );
1531
1606
  }
1532
1607
 
1533
1608
  autoImportRunning = true;
1534
1609
  let progressSeconds = 0;
1535
1610
  const progressInterval = setInterval(() => {
1536
1611
  progressSeconds += 5;
1537
- onOutput(`Processing usage data... (${progressSeconds}s)`);
1612
+ onProgress(createAutoImportMessageEvent('processingUsageData', { seconds: progressSeconds }));
1538
1613
  }, 5000);
1539
1614
 
1540
1615
  try {
1541
1616
  onCheck({ tool: 'toktrack', status: 'checking' });
1542
- onProgress({ message: 'Starting local toktrack import...' });
1617
+ onProgress(createAutoImportMessageEvent('startingLocalImport'));
1543
1618
 
1544
1619
  const runner = await resolveToktrackRunner();
1545
1620
  if (!runner) {
1546
1621
  onCheck({ tool: 'toktrack', status: 'not_found' });
1547
- throw new Error('No local toktrack, Bun, or npm exec installation found.');
1622
+ throw createAutoImportError(
1623
+ 'No local toktrack, Bun, or npm exec installation found.',
1624
+ 'noRunnerFound',
1625
+ );
1548
1626
  }
1549
1627
 
1550
1628
  const versionResult = await runToktrack(runner, ['--version']);
@@ -1554,7 +1632,11 @@ async function performAutoImport({
1554
1632
  method: runner.label,
1555
1633
  version: String(versionResult).replace(/^toktrack\s+/, ''),
1556
1634
  });
1557
- onProgress({ message: `Loading usage data via ${runner.displayCommand}...` });
1635
+ onProgress(
1636
+ createAutoImportMessageEvent('loadingUsageData', {
1637
+ command: runner.displayCommand,
1638
+ }),
1639
+ );
1558
1640
 
1559
1641
  const rawJson = await runToktrack(runner, ['daily', '--json'], {
1560
1642
  streamStderr: true,
@@ -1590,7 +1672,7 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
1590
1672
  }
1591
1673
  },
1592
1674
  onProgress: (event) => {
1593
- console.log(event.message);
1675
+ console.log(formatAutoImportMessageEvent(event));
1594
1676
  },
1595
1677
  onOutput: (line) => {
1596
1678
  console.log(line);
@@ -1610,15 +1692,34 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
1610
1692
  // --- Server ---
1611
1693
 
1612
1694
  const server = http.createServer(async (req, res) => {
1613
- const url = new URL(req.url, 'http://localhost');
1614
- const pathname = decodeURIComponent(url.pathname);
1695
+ let url;
1696
+ let pathname;
1697
+
1698
+ try {
1699
+ url = new URL(req.url, 'http://localhost');
1700
+ pathname = decodeURIComponent(url.pathname);
1701
+ } catch {
1702
+ return json(res, 400, { message: 'Invalid request path' });
1703
+ }
1615
1704
 
1616
1705
  // API routing
1617
1706
  const apiPath = resolveApiPath(pathname);
1618
1707
 
1708
+ if (apiPath === null && (pathname === '/api' || pathname.startsWith('/api/'))) {
1709
+ return json(res, 404, { message: 'Not Found' });
1710
+ }
1711
+
1619
1712
  if (apiPath === '/usage') {
1620
1713
  if (req.method === 'GET') {
1621
- const data = readData();
1714
+ let data;
1715
+ try {
1716
+ data = readData();
1717
+ } catch (error) {
1718
+ if (isPersistedStateError(error, 'usage')) {
1719
+ return json(res, 500, { message: error.message });
1720
+ }
1721
+ throw error;
1722
+ }
1622
1723
  return json(
1623
1724
  res,
1624
1725
  200,
@@ -1638,6 +1739,10 @@ const server = http.createServer(async (req, res) => {
1638
1739
  );
1639
1740
  }
1640
1741
  if (req.method === 'DELETE') {
1742
+ const validationError = validateMutationRequest(req);
1743
+ if (validationError) {
1744
+ return json(res, validationError.status, { message: validationError.message });
1745
+ }
1641
1746
  try {
1642
1747
  fs.unlinkSync(DATA_FILE);
1643
1748
  } catch {
@@ -1666,10 +1771,21 @@ const server = http.createServer(async (req, res) => {
1666
1771
 
1667
1772
  if (apiPath === '/settings') {
1668
1773
  if (req.method === 'GET') {
1669
- return json(res, 200, readSettings());
1774
+ try {
1775
+ return json(res, 200, readSettings());
1776
+ } catch (error) {
1777
+ if (isPersistedStateError(error, 'settings')) {
1778
+ return json(res, 500, { message: error.message });
1779
+ }
1780
+ throw error;
1781
+ }
1670
1782
  }
1671
1783
 
1672
1784
  if (req.method === 'DELETE') {
1785
+ const validationError = validateMutationRequest(req);
1786
+ if (validationError) {
1787
+ return json(res, validationError.status, { message: validationError.message });
1788
+ }
1673
1789
  try {
1674
1790
  fs.unlinkSync(SETTINGS_FILE);
1675
1791
  } catch {
@@ -1679,10 +1795,17 @@ const server = http.createServer(async (req, res) => {
1679
1795
  }
1680
1796
 
1681
1797
  if (req.method === 'PATCH') {
1798
+ const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
1799
+ if (validationError) {
1800
+ return json(res, validationError.status, { message: validationError.message });
1801
+ }
1682
1802
  try {
1683
1803
  const body = await readBody(req);
1684
1804
  return json(res, 200, updateSettings(body));
1685
1805
  } catch (e) {
1806
+ if (isPayloadTooLargeError(e)) {
1807
+ return json(res, 413, { message: 'Settings request too large' });
1808
+ }
1686
1809
  return json(res, 400, { message: e.message || 'Invalid settings request' });
1687
1810
  }
1688
1811
  }
@@ -1695,18 +1818,31 @@ const server = http.createServer(async (req, res) => {
1695
1818
  return json(res, 405, { message: 'Method Not Allowed' });
1696
1819
  }
1697
1820
 
1821
+ const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
1822
+ if (validationError) {
1823
+ return json(res, validationError.status, { message: validationError.message });
1824
+ }
1825
+
1698
1826
  try {
1699
1827
  const body = await readBody(req);
1700
1828
  const importedSettings = normalizeSettings(extractSettingsImportPayload(body));
1701
1829
  writeSettings(importedSettings);
1702
1830
  return json(res, 200, toSettingsResponse(importedSettings));
1703
1831
  } catch (e) {
1832
+ if (isPayloadTooLargeError(e)) {
1833
+ return json(res, 413, { message: 'Settings file too large' });
1834
+ }
1704
1835
  return json(res, 400, { message: e.message || 'Invalid settings file' });
1705
1836
  }
1706
1837
  }
1707
1838
 
1708
1839
  if (apiPath === '/upload') {
1709
1840
  if (req.method === 'POST') {
1841
+ const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
1842
+ if (validationError) {
1843
+ return json(res, validationError.status, { message: validationError.message });
1844
+ }
1845
+
1710
1846
  try {
1711
1847
  const body = await readBody(req);
1712
1848
  const normalized = normalizeIncomingData(body);
@@ -1716,11 +1852,10 @@ const server = http.createServer(async (req, res) => {
1716
1852
  const totalCost = normalized.totals.totalCost;
1717
1853
  return json(res, 200, { days, totalCost });
1718
1854
  } catch (e) {
1719
- const status = e.message === 'Payload too large' ? 413 : 400;
1720
- const message =
1721
- e.message === 'Payload too large'
1722
- ? 'File too large (max. 10 MB)'
1723
- : e.message || 'Invalid JSON';
1855
+ const status = isPayloadTooLargeError(e) ? 413 : 400;
1856
+ const message = isPayloadTooLargeError(e)
1857
+ ? 'File too large (max. 10 MB)'
1858
+ : e.message || 'Invalid JSON';
1724
1859
  return json(res, status, { message });
1725
1860
  }
1726
1861
  }
@@ -1732,6 +1867,11 @@ const server = http.createServer(async (req, res) => {
1732
1867
  return json(res, 405, { message: 'Method Not Allowed' });
1733
1868
  }
1734
1869
 
1870
+ const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
1871
+ if (validationError) {
1872
+ return json(res, validationError.status, { message: validationError.message });
1873
+ }
1874
+
1735
1875
  try {
1736
1876
  const body = await readBody(req);
1737
1877
  const importedData = normalizeIncomingData(extractUsageImportPayload(body));
@@ -1741,15 +1881,26 @@ const server = http.createServer(async (req, res) => {
1741
1881
  recordDataLoad('file');
1742
1882
  return json(res, 200, result.summary);
1743
1883
  } catch (e) {
1884
+ if (isPayloadTooLargeError(e)) {
1885
+ return json(res, 413, { message: 'Usage backup file too large' });
1886
+ }
1887
+ if (isPersistedStateError(e, 'usage')) {
1888
+ return json(res, 500, { message: e.message });
1889
+ }
1744
1890
  return json(res, 400, { message: e.message || 'Invalid usage backup file' });
1745
1891
  }
1746
1892
  }
1747
1893
 
1748
1894
  if (apiPath === '/auto-import/stream') {
1749
- if (req.method !== 'GET') {
1895
+ if (req.method !== 'POST') {
1750
1896
  return json(res, 405, { message: 'Method Not Allowed' });
1751
1897
  }
1752
1898
 
1899
+ const validationError = validateMutationRequest(req);
1900
+ if (validationError) {
1901
+ return json(res, validationError.status, { message: validationError.message });
1902
+ }
1903
+
1753
1904
  res.writeHead(200, {
1754
1905
  'Content-Type': 'text/event-stream',
1755
1906
  'Cache-Control': 'no-cache',
@@ -1797,7 +1948,7 @@ const server = http.createServer(async (req, res) => {
1797
1948
  if (aborted) {
1798
1949
  return;
1799
1950
  }
1800
- sendSSE(res, 'error', { message: `Error: ${err.message}` });
1951
+ sendSSE(res, 'error', toAutoImportErrorEvent(err));
1801
1952
  sendSSE(res, 'done', {});
1802
1953
  res.end();
1803
1954
  }
@@ -1809,7 +1960,20 @@ const server = http.createServer(async (req, res) => {
1809
1960
  return json(res, 405, { message: 'Method Not Allowed' });
1810
1961
  }
1811
1962
 
1812
- const data = readData();
1963
+ const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
1964
+ if (validationError) {
1965
+ return json(res, validationError.status, { message: validationError.message });
1966
+ }
1967
+
1968
+ let data;
1969
+ try {
1970
+ data = readData();
1971
+ } catch (error) {
1972
+ if (isPersistedStateError(error, 'usage')) {
1973
+ return json(res, 500, { message: error.message });
1974
+ }
1975
+ throw error;
1976
+ }
1813
1977
  if (!data || !Array.isArray(data.daily) || data.daily.length === 0) {
1814
1978
  return json(res, 400, { message: 'No data available for the report.' });
1815
1979
  }
@@ -1818,10 +1982,9 @@ const server = http.createServer(async (req, res) => {
1818
1982
  try {
1819
1983
  body = await readBody(req);
1820
1984
  } catch (e) {
1821
- const status = e.message === 'Payload too large' ? 413 : 400;
1985
+ const status = isPayloadTooLargeError(e) ? 413 : 400;
1822
1986
  return json(res, status, {
1823
- message:
1824
- e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request',
1987
+ message: isPayloadTooLargeError(e) ? 'Report request too large' : 'Invalid report request',
1825
1988
  });
1826
1989
  }
1827
1990
 
@@ -1861,61 +2024,12 @@ const server = http.createServer(async (req, res) => {
1861
2024
  serveFile(res, filePath);
1862
2025
  });
1863
2026
 
1864
- function createNoFreePortError(rangeStartPort, maxPort) {
1865
- return new Error(`No free port found (${rangeStartPort}-${maxPort})`);
1866
- }
1867
-
1868
- async function listenOnAvailablePort(
1869
- serverInstance,
1870
- port,
1871
- maxPort,
1872
- bindHost,
1873
- log = console.log,
1874
- rangeStartPort = port,
1875
- ) {
1876
- if (port > maxPort) {
1877
- throw createNoFreePortError(rangeStartPort, maxPort);
1878
- }
1879
-
1880
- for (let currentPort = port; currentPort <= maxPort; currentPort += 1) {
1881
- try {
1882
- await new Promise((resolve, reject) => {
1883
- const onError = (err) => {
1884
- serverInstance.off('listening', onListening);
1885
- reject(err);
1886
- };
1887
-
1888
- const onListening = () => {
1889
- serverInstance.off('error', onError);
1890
- resolve();
1891
- };
1892
-
1893
- serverInstance.once('error', onError);
1894
- serverInstance.once('listening', onListening);
1895
- serverInstance.listen(currentPort, bindHost);
1896
- });
1897
-
1898
- return currentPort;
1899
- } catch (err) {
1900
- if (err && err.code === 'EADDRINUSE') {
1901
- if (currentPort >= maxPort) {
1902
- throw createNoFreePortError(rangeStartPort, maxPort);
1903
- }
1904
- log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`);
1905
- continue;
1906
- }
1907
- throw err;
1908
- }
1909
- }
1910
-
1911
- throw createNoFreePortError(rangeStartPort, maxPort);
1912
- }
1913
-
1914
2027
  function tryListen(port) {
1915
2028
  return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT);
1916
2029
  }
1917
2030
 
1918
2031
  async function start() {
2032
+ ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND);
1919
2033
  ensureAppDirs();
1920
2034
  migrateLegacyDataFile();
1921
2035
 
@@ -1979,6 +2093,7 @@ module.exports = {
1979
2093
  bootstrapCli,
1980
2094
  runCli,
1981
2095
  __test__: {
2096
+ commandExists,
1982
2097
  getExecutableName,
1983
2098
  listenOnAvailablePort,
1984
2099
  },