tokentrace 0.18.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ All notable changes to TokenTrace are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.19.0] - 2026-06-04
8
+
9
+ ### Security
10
+
11
+ - **The local dashboard now enforces a request perimeter.** All `/api/*` routes
12
+ go through `middleware.ts`, which rejects requests whose `Host` is not a
13
+ loopback name (defeating DNS-rebinding that would otherwise let a malicious
14
+ web page read your local data as "same-origin") and blocks cross-site
15
+ state-changing requests (defeating CSRF that could silently re-point the
16
+ scanner, change settings, or wipe imported data). Non-browser clients (CLI,
17
+ curl) are unaffected.
18
+ - **File-preview endpoints are now contained.** `/api/parser-debug/preview` and
19
+ `/api/import-profile-preview` previously read any caller-supplied path. They
20
+ now resolve symlinks and only read files under your home directory, the OS
21
+ temp directory, or explicitly configured import folders, returning `403` for
22
+ anything else. This removes an arbitrary-file-read surface.
23
+ - **`tokentrace serve` refuses non-loopback binds by default.** Binding to
24
+ `0.0.0.0` or a LAN address (via `--hostname` or `TOKENTRACE_HOSTNAME`) now
25
+ errors with guidance, since the dashboard is unauthenticated. Set
26
+ `TOKENTRACE_ALLOW_REMOTE=1` to override deliberately.
27
+ - **Security response headers added.** The dashboard now sends a strict
28
+ same-origin `Content-Security-Policy`, `X-Frame-Options: DENY`,
29
+ `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and a
30
+ restrictive `Permissions-Policy` (anti-clickjacking and anti-sniffing).
31
+
32
+ ## [0.18.1] - 2026-05-28
33
+
34
+ ### Fixed
35
+
36
+ - **Scheduled scans now coalesce concurrent runs.** `runDueScheduledScan`
37
+ fires on every overview page load; two overlapping loads (a second tab, a
38
+ quick refresh, a prefetch) previously both passed the due-check and both
39
+ called `runScan`, producing duplicate scan runs. A module-scoped in-flight
40
+ promise guard now coalesces concurrent callers into a single scan within
41
+ the server process.
42
+ - **`tokentrace repair auto-classify --apply` is now atomic.** The alias
43
+ writes and cost backfills for a batch run inside a single SQLite
44
+ transaction, and `backfillAlias` wraps its per-row updates in a transaction
45
+ too, so a failure partway can no longer leave the local database
46
+ half-applied with inaccurate reported counts.
47
+ - **`tokentrace query` surfaces range errors cleanly.** Range validation
48
+ (e.g. `--from` after `--to`, or a preset combined with explicit dates) now
49
+ prints the validation message plus usage and exits non-zero, instead of
50
+ letting a raw stack trace escape.
51
+ - **Anomaly z-scores survive JSON.** The flat-window (`mad === 0`) severe
52
+ branch reported `Infinity`, which `JSON.stringify` turned into `null` and
53
+ silently dropped the signal. It now reports a finite sentinel z-score.
54
+ - **Stricter query CLI parsing.** Empty `--flag=` values are rejected with a
55
+ clear "Missing value" message rather than flowing through as empty strings.
56
+
57
+ ### Internal
58
+
59
+ - **Test suite no longer flakes under parallel load.** CLI/MCP integration
60
+ tests spawn `bin/tokentrace.js`, which itself spawns `db-migrate` and
61
+ `db-seed`, fanning out to ~3x the core count in node processes. The Vitest
62
+ fork pool is now capped at roughly half the available cores with one
63
+ generous global timeout, removing the scattered per-test timeout overrides.
64
+ - Removed the dead `tailwind.config.ts` left over from the Tailwind 4
65
+ (CSS-first) migration.
66
+
7
67
  ## [0.18.0] - 2026-05-28
8
68
 
9
69
  ### Local intelligence
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { getAppSettings } from "@/src/db/settings";
2
3
  import { readJsonObject } from "@/src/lib/api-json";
3
4
  import { buildImportProfilePreview } from "@/src/lib/import-profile-preview";
5
+ import { PathAccessError, pathAccessStatus, resolveReadablePath } from "@/src/lib/path-access";
4
6
 
5
7
  export const dynamic = "force-dynamic";
6
8
 
@@ -11,9 +13,22 @@ export async function POST(request: Request) {
11
13
  if (typeof filePath !== "string" || !filePath.trim()) {
12
14
  return NextResponse.json({ error: "filePath is required." }, { status: 400 });
13
15
  }
16
+
17
+ let resolvedPath: string;
18
+ try {
19
+ resolvedPath = await resolveReadablePath(filePath, {
20
+ extraRoots: getAppSettings().customFolders
21
+ });
22
+ } catch (error) {
23
+ if (error instanceof PathAccessError) {
24
+ return NextResponse.json({ error: error.message }, { status: pathAccessStatus(error.code) });
25
+ }
26
+ return NextResponse.json({ error: "Preview failed." }, { status: 400 });
27
+ }
28
+
14
29
  try {
15
30
  const preview = await buildImportProfilePreview({
16
- filePath: filePath.trim(),
31
+ filePath: resolvedPath,
17
32
  storeRawMessageContent: parsed.body.storeRawMessageContent === true
18
33
  });
19
34
  return NextResponse.json(preview);
@@ -1,7 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { NextResponse } from "next/server";
3
+ import { getAppSettings } from "@/src/db/settings";
3
4
  import { adapters } from "@/src/ingestion/adapters";
4
5
  import type { FileCandidate, NormalizedInteraction } from "@/src/ingestion/types";
6
+ import { PathAccessError, pathAccessStatus, resolveReadablePath } from "@/src/lib/path-access";
5
7
 
6
8
  export const dynamic = "force-dynamic";
7
9
 
@@ -18,10 +20,10 @@ export async function POST(request: Request) {
18
20
  return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
19
21
  }
20
22
 
21
- const filePath = typeof body.path === "string" ? body.path.trim() : "";
23
+ const requestedPath = typeof body.path === "string" ? body.path.trim() : "";
22
24
  const parserId = typeof body.parserId === "string" ? body.parserId.trim() : "";
23
25
 
24
- if (!filePath) {
26
+ if (!requestedPath) {
25
27
  return NextResponse.json({ error: "path is required" }, { status: 400 });
26
28
  }
27
29
  if (!parserId) {
@@ -36,11 +38,23 @@ export async function POST(request: Request) {
36
38
  );
37
39
  }
38
40
 
41
+ let filePath: string;
42
+ try {
43
+ filePath = await resolveReadablePath(requestedPath, {
44
+ extraRoots: getAppSettings().customFolders
45
+ });
46
+ } catch (error) {
47
+ if (error instanceof PathAccessError) {
48
+ return NextResponse.json({ error: error.message }, { status: pathAccessStatus(error.code) });
49
+ }
50
+ return NextResponse.json({ error: `file not found: ${requestedPath}` }, { status: 404 });
51
+ }
52
+
39
53
  let stat;
40
54
  try {
41
55
  stat = await fs.stat(filePath);
42
56
  } catch {
43
- return NextResponse.json({ error: `file not found: ${filePath}` }, { status: 404 });
57
+ return NextResponse.json({ error: `file not found: ${requestedPath}` }, { status: 404 });
44
58
  }
45
59
 
46
60
  const candidate: FileCandidate = {
@@ -846,7 +846,7 @@ function scoreValue(value, window, thresholds) {
846
846
  }
847
847
  const flatRunLength = window.filter((entry) => entry === baseline).length;
848
848
  if (value > 2 * baseline && flatRunLength >= 3) {
849
- return { severity: "severe", baseline, zScore: Number.POSITIVE_INFINITY };
849
+ return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
850
850
  }
851
851
  return { severity: null, baseline, zScore: 0 };
852
852
  }
@@ -907,7 +907,7 @@ function detectAnomalies(points, options2 = {}) {
907
907
  summary
908
908
  };
909
909
  }
910
- var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, METRICS;
910
+ var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, FLAT_RUN_Z, METRICS;
911
911
  var init_anomaly_detection = __esm({
912
912
  "src/lib/anomaly-detection.ts"() {
913
913
  "use strict";
@@ -915,6 +915,7 @@ var init_anomaly_detection = __esm({
915
915
  DEFAULT_MIN_WINDOW = 5;
916
916
  DEFAULT_THRESHOLDS = { notable: 3, high: 4.5, severe: 6 };
917
917
  MAD_TO_STDDEV = 0.6745;
918
+ FLAT_RUN_Z = 999;
918
919
  METRICS = [
919
920
  { metric: "tokens", valueOf: (point) => point.totalTokens },
920
921
  { metric: "cost", valueOf: (point) => point.cost }
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
1659
1659
  currency: interaction.alias_currency ?? "USD"
1660
1660
  } : null;
1661
1661
  const resolvedPrice = aliasPrice ?? directPrice;
1662
- const resolvedViaAlias = aliasPrice != null;
1663
1662
  const cost = calculateInteractionCost(
1664
1663
  {
1665
1664
  inputTokens: interaction.input_tokens,
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
1671
1670
  },
1672
1671
  resolvedPrice
1673
1672
  );
1673
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
1674
1674
  if (cost.status === "unknown") unknownCostInteractions += 1;
1675
1675
  const metadata = {
1676
1676
  ...existingMetadata,
@@ -940,7 +940,6 @@ function recalculateInteractionCosts() {
940
940
  currency: interaction.alias_currency ?? "USD"
941
941
  } : null;
942
942
  const resolvedPrice = aliasPrice ?? directPrice;
943
- const resolvedViaAlias = aliasPrice != null;
944
943
  const cost = calculateInteractionCost(
945
944
  {
946
945
  inputTokens: interaction.input_tokens,
@@ -952,6 +951,7 @@ function recalculateInteractionCosts() {
952
951
  },
953
952
  resolvedPrice
954
953
  );
954
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
955
955
  if (cost.status === "unknown") unknownCostInteractions += 1;
956
956
  const metadata = {
957
957
  ...existingMetadata,
@@ -942,7 +942,11 @@ var SORT = ["asc", "desc"];
942
942
  var PRESET = ["today", "7d", "30d", "60d", "90d", "all"];
943
943
  function valueOf(arg, next) {
944
944
  const eq = arg.indexOf("=");
945
- if (eq >= 0) return { value: arg.slice(eq + 1), consumedNext: false };
945
+ if (eq >= 0) {
946
+ const value = arg.slice(eq + 1);
947
+ if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
948
+ return { value, consumedNext: false };
949
+ }
946
950
  if (next == null || next.startsWith("-")) {
947
951
  throw new Error(`Missing value for ${arg}`);
948
952
  }
@@ -1075,7 +1079,14 @@ if (parsed.help) {
1075
1079
  process.exit(0);
1076
1080
  }
1077
1081
  var { runStructuredQuery: runStructuredQuery2 } = await Promise.resolve().then(() => (init_structured_query(), structured_query_exports));
1078
- var result = runStructuredQuery2(parsed.args);
1082
+ var result;
1083
+ try {
1084
+ result = runStructuredQuery2(parsed.args);
1085
+ } catch (error) {
1086
+ console.error(error instanceof Error ? error.message : "Invalid query.");
1087
+ console.error(structuredQueryUsage());
1088
+ process.exit(1);
1089
+ }
1079
1090
  if (parsed.json) {
1080
1091
  console.log(JSON.stringify(result, null, 2));
1081
1092
  } else {
@@ -897,6 +897,12 @@ var init_schema = __esm({
897
897
  });
898
898
 
899
899
  // src/db/client.ts
900
+ var client_exports = {};
901
+ __export(client_exports, {
902
+ db: () => db,
903
+ getDatabasePath: () => getDatabasePath,
904
+ sqlite: () => sqlite
905
+ });
900
906
  import fs from "node:fs";
901
907
  import path from "node:path";
902
908
  import { fileURLToPath } from "node:url";
@@ -910,6 +916,9 @@ function databaseUrlPath(value) {
910
916
  return value.slice("file:".length);
911
917
  }
912
918
  }
919
+ function getDatabasePath() {
920
+ return dbPath;
921
+ }
913
922
  var defaultDbPath, dbPath, sqlite, db;
914
923
  var init_client = __esm({
915
924
  "src/db/client.ts"() {
@@ -2001,9 +2010,12 @@ function backfillAlias(alias, options2 = {}) {
2001
2010
  const stmt = prepareCached(
2002
2011
  "UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
2003
2012
  );
2004
- for (const update of updates) {
2005
- stmt.run(update.cost, update.id);
2006
- }
2013
+ const writeAll = sqlite.transaction((rows4) => {
2014
+ for (const update of rows4) {
2015
+ stmt.run(update.cost, update.id);
2016
+ }
2017
+ });
2018
+ writeAll(updates);
2007
2019
  }
2008
2020
  return {
2009
2021
  alias,
@@ -2016,6 +2028,7 @@ function backfillAlias(alias, options2 = {}) {
2016
2028
  var init_backfill = __esm({
2017
2029
  "src/lib/model-aliases/backfill.ts"() {
2018
2030
  "use strict";
2031
+ init_client();
2019
2032
  init_prepared();
2020
2033
  init_cost();
2021
2034
  }
@@ -2248,10 +2261,11 @@ if (args[0] === "auto-classify") {
2248
2261
  const workbench2 = buildUnknownCostRepairWorkbench3();
2249
2262
  const result = buildAutoClassifyResult2(workbench2, { minConfidence: options2.minConfidence });
2250
2263
  if (options2.apply) {
2251
- const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }] = await Promise.all([
2264
+ const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }, { sqlite: sqlite2 }] = await Promise.all([
2252
2265
  Promise.resolve().then(() => (init_store(), store_exports)),
2253
2266
  Promise.resolve().then(() => (init_backfill(), backfill_exports)),
2254
- Promise.resolve().then(() => (init_prepared(), prepared_exports))
2267
+ Promise.resolve().then(() => (init_prepared(), prepared_exports)),
2268
+ Promise.resolve().then(() => (init_client(), client_exports))
2255
2269
  ]);
2256
2270
  const outcome = {
2257
2271
  dryRun: options2.dryRun,
@@ -2271,18 +2285,18 @@ if (args[0] === "auto-classify") {
2271
2285
  ).get(modelName, providerName, providerName);
2272
2286
  return row2 ?? null;
2273
2287
  };
2274
- for (const suggestion of result.suggestions) {
2288
+ const processSuggestion = (suggestion) => {
2275
2289
  const c = suggestion.classification;
2276
2290
  if (c.rule === "none" || !c.suggestedModel) {
2277
2291
  outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
2278
- continue;
2292
+ return;
2279
2293
  }
2280
2294
  if (c.rule === "parser-source") {
2281
2295
  outcome.skipped.push({
2282
2296
  key: suggestion.key,
2283
2297
  reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
2284
2298
  });
2285
- continue;
2299
+ return;
2286
2300
  }
2287
2301
  const resolved = resolvePricedModelId(
2288
2302
  c.suggestedProvider ?? suggestion.provider,
@@ -2293,7 +2307,7 @@ if (args[0] === "auto-classify") {
2293
2307
  key: suggestion.key,
2294
2308
  reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
2295
2309
  });
2296
- continue;
2310
+ return;
2297
2311
  }
2298
2312
  if (!options2.dryRun) {
2299
2313
  upsertAlias2({
@@ -2325,6 +2339,14 @@ if (args[0] === "auto-classify") {
2325
2339
  affectedInteractions: backfill.affectedInteractions,
2326
2340
  addedCost: backfill.totalCost
2327
2341
  });
2342
+ };
2343
+ if (options2.dryRun) {
2344
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
2345
+ } else {
2346
+ const applyBatch = sqlite2.transaction(() => {
2347
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
2348
+ });
2349
+ applyBatch();
2328
2350
  }
2329
2351
  result.applied = outcome;
2330
2352
  }
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
1659
1659
  currency: interaction.alias_currency ?? "USD"
1660
1660
  } : null;
1661
1661
  const resolvedPrice = aliasPrice ?? directPrice;
1662
- const resolvedViaAlias = aliasPrice != null;
1663
1662
  const cost = calculateInteractionCost(
1664
1663
  {
1665
1664
  inputTokens: interaction.input_tokens,
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
1671
1670
  },
1672
1671
  resolvedPrice
1673
1672
  );
1673
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
1674
1674
  if (cost.status === "unknown") unknownCostInteractions += 1;
1675
1675
  const metadata = {
1676
1676
  ...existingMetadata,
@@ -4337,7 +4337,6 @@ function recalculateInteractionCosts() {
4337
4337
  currency: interaction.alias_currency ?? "USD"
4338
4338
  } : null;
4339
4339
  const resolvedPrice = aliasPrice ?? directPrice;
4340
- const resolvedViaAlias = aliasPrice != null;
4341
4340
  const cost = calculateInteractionCost(
4342
4341
  {
4343
4342
  inputTokens: interaction.input_tokens,
@@ -4349,6 +4348,7 @@ function recalculateInteractionCosts() {
4349
4348
  },
4350
4349
  resolvedPrice
4351
4350
  );
4351
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
4352
4352
  if (cost.status === "unknown") unknownCostInteractions += 1;
4353
4353
  const metadata = {
4354
4354
  ...existingMetadata,
package/next.config.mjs CHANGED
@@ -16,17 +16,54 @@ const productionExperimentalConfig =
16
16
  ? { ...baseExperimental, serverMinification: false }
17
17
  : baseExperimental;
18
18
 
19
+ // Defense-in-depth response headers for the local dashboard. The CSP keeps all
20
+ // resource loads same-origin (no third-party script/connect surface), and the
21
+ // frame protections block clickjacking of the unauthenticated UI. 'unsafe-inline'
22
+ // is required because Next.js injects inline bootstrap/hydration scripts and
23
+ // styles; everything else is locked to 'self'.
24
+ const securityHeaders = [
25
+ { key: "X-Frame-Options", value: "DENY" },
26
+ { key: "X-Content-Type-Options", value: "nosniff" },
27
+ { key: "Referrer-Policy", value: "no-referrer" },
28
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
29
+ {
30
+ key: "Content-Security-Policy",
31
+ value: [
32
+ "default-src 'self'",
33
+ "script-src 'self' 'unsafe-inline'",
34
+ "style-src 'self' 'unsafe-inline'",
35
+ "img-src 'self' data: blob:",
36
+ "font-src 'self' data:",
37
+ "connect-src 'self'",
38
+ "object-src 'none'",
39
+ "base-uri 'self'",
40
+ "form-action 'self'",
41
+ "frame-ancestors 'none'"
42
+ ].join("; ")
43
+ }
44
+ ];
45
+
19
46
  /** @type {import('next').NextConfig} */
20
47
  const nextConfig = {
21
48
  allowedDevOrigins: ["localhost", "127.0.0.1"],
22
49
  devIndicators: false,
23
50
  experimental: productionExperimentalConfig,
51
+ // The published package ships source and runs `next build` on the end user's
52
+ // machine at first `tokentrace serve`. Production installs omit devDependencies
53
+ // such as @types/better-sqlite3, so Next's build-time type check cannot pass
54
+ // there. Type safety is enforced in development and CI via `npm run verify`
55
+ // (`tsc --noEmit`); this only disables the redundant check during the
56
+ // user-machine build. Do not remove without making the user-side build
57
+ // type-check-free another way.
24
58
  typescript: {
25
59
  ignoreBuildErrors: true
26
60
  },
27
61
  serverExternalPackages: ["better-sqlite3"],
28
62
  outputFileTracingRoot: projectRoot,
29
- typedRoutes: false
63
+ typedRoutes: false,
64
+ async headers() {
65
+ return [{ source: "/:path*", headers: securityHeaders }];
66
+ }
30
67
  };
31
68
 
32
69
  // Bundle analyzer — install @next/bundle-analyzer as an optional devDep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentrace",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "mcpName": "io.github.abhiyoheswaran1/tokentrace",
5
5
  "description": "Local-first dashboard for AI CLI token, cost, and session analytics.",
6
6
  "author": {
package/scripts/query.ts CHANGED
@@ -17,7 +17,18 @@ if (parsed.help) {
17
17
  }
18
18
 
19
19
  const { runStructuredQuery } = await import("@/src/lib/structured-query");
20
- const result = runStructuredQuery(parsed.args);
20
+
21
+ let result: ReturnType<typeof runStructuredQuery>;
22
+ try {
23
+ // Range/argument validation (e.g. from >= to, preset + from/to together)
24
+ // lives in runStructuredQuery, so surface those the same clean way as parse
25
+ // errors instead of letting a raw stack trace escape.
26
+ result = runStructuredQuery(parsed.args);
27
+ } catch (error) {
28
+ console.error(error instanceof Error ? error.message : "Invalid query.");
29
+ console.error(structuredQueryUsage());
30
+ process.exit(1);
31
+ }
21
32
 
22
33
  if (parsed.json) {
23
34
  console.log(JSON.stringify(result, null, 2));
package/scripts/repair.ts CHANGED
@@ -49,10 +49,11 @@ if (args[0] === "auto-classify") {
49
49
  const result = buildAutoClassifyResult(workbench, { minConfidence: options.minConfidence });
50
50
 
51
51
  if (options.apply) {
52
- const [{ upsertAlias }, { backfillAlias }, { prepareCached }] = await Promise.all([
52
+ const [{ upsertAlias }, { backfillAlias }, { prepareCached }, { sqlite }] = await Promise.all([
53
53
  import("@/src/lib/model-aliases/store"),
54
54
  import("@/src/lib/model-aliases/backfill"),
55
- import("@/src/db/prepared")
55
+ import("@/src/db/prepared"),
56
+ import("@/src/db/client")
56
57
  ]);
57
58
 
58
59
  const outcome: NonNullable<typeof result.applied> = {
@@ -79,18 +80,18 @@ if (args[0] === "auto-classify") {
79
80
  return row ?? null;
80
81
  };
81
82
 
82
- for (const suggestion of result.suggestions) {
83
+ const processSuggestion = (suggestion: (typeof result.suggestions)[number]) => {
83
84
  const c = suggestion.classification;
84
85
  if (c.rule === "none" || !c.suggestedModel) {
85
86
  outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
86
- continue;
87
+ return;
87
88
  }
88
89
  if (c.rule === "parser-source") {
89
90
  outcome.skipped.push({
90
91
  key: suggestion.key,
91
92
  reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
92
93
  });
93
- continue;
94
+ return;
94
95
  }
95
96
  const resolved = resolvePricedModelId(
96
97
  c.suggestedProvider ?? suggestion.provider,
@@ -101,7 +102,7 @@ if (args[0] === "auto-classify") {
101
102
  key: suggestion.key,
102
103
  reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
103
104
  });
104
- continue;
105
+ return;
105
106
  }
106
107
 
107
108
  if (!options.dryRun) {
@@ -136,6 +137,18 @@ if (args[0] === "auto-classify") {
136
137
  affectedInteractions: backfill.affectedInteractions,
137
138
  addedCost: backfill.totalCost
138
139
  });
140
+ };
141
+
142
+ if (options.dryRun) {
143
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
144
+ } else {
145
+ // Apply the whole batch atomically: every alias + cost backfill commits
146
+ // together, or none does, so a failure partway can't leave the local
147
+ // database half-applied with inaccurate reported counts.
148
+ const applyBatch = sqlite.transaction(() => {
149
+ for (const suggestion of result.suggestions) processSuggestion(suggestion);
150
+ });
151
+ applyBatch();
139
152
  }
140
153
 
141
154
  result.applied = outcome;
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "url": "https://github.com/abhiyoheswaran1/tokentrace",
9
9
  "source": "github"
10
10
  },
11
- "version": "0.18.0",
11
+ "version": "0.19.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "tokentrace",
16
- "version": "0.18.0",
16
+ "version": "0.19.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -0,0 +1,32 @@
1
+ export interface ServeOptions {
2
+ help: boolean;
3
+ hostname: string;
4
+ port: number | null;
5
+ openBrowser: boolean;
6
+ }
7
+
8
+ export interface ResolvedServePort {
9
+ port: number;
10
+ fixed: boolean;
11
+ }
12
+
13
+ export interface ServeContext {
14
+ appDataDir(): string;
15
+ nextBin(): string;
16
+ runtimeEnv(): NodeJS.ProcessEnv;
17
+ }
18
+
19
+ export function parsePort(value: unknown): number;
20
+ export function parseServeOptions(args: string[], env?: NodeJS.ProcessEnv): ServeOptions;
21
+ export function isLoopbackHostname(hostname: unknown): boolean;
22
+ export function assertHostnameAllowed(
23
+ hostname: string,
24
+ env?: Record<string, string | undefined>
25
+ ): void;
26
+ export function startupProgress(step: string, detail?: string): void;
27
+ export function formatServeError(
28
+ error: unknown,
29
+ options?: { hostname?: string; port?: number | null }
30
+ ): string;
31
+ export function resolveServePort(options: ServeOptions): Promise<ResolvedServePort>;
32
+ export function serve(context: ServeContext, args?: string[]): Promise<void>;
package/src/cli/serve.js CHANGED
@@ -16,6 +16,28 @@ export function parsePort(value) {
16
16
  return port;
17
17
  }
18
18
 
19
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
20
+
21
+ export function isLoopbackHostname(hostname) {
22
+ return LOOPBACK_HOSTNAMES.has(String(hostname ?? "").trim().toLowerCase());
23
+ }
24
+
25
+ /**
26
+ * The dashboard ships no authentication, so binding it to anything other than
27
+ * loopback exposes local AI usage data and the file-preview endpoints to the
28
+ * network. Refuse non-loopback binds unless the operator explicitly opts in.
29
+ */
30
+ export function assertHostnameAllowed(hostname, env = process.env) {
31
+ if (isLoopbackHostname(hostname)) return;
32
+ if (env.TOKENTRACE_ALLOW_REMOTE === "1") return;
33
+ throw new Error(
34
+ `Refusing to bind TokenTrace to non-loopback host "${hostname}". ` +
35
+ "The dashboard has no authentication, so this would expose your local AI usage data " +
36
+ "and file-preview endpoints to the network. " +
37
+ "Re-run with --hostname 127.0.0.1, or set TOKENTRACE_ALLOW_REMOTE=1 to override (not recommended)."
38
+ );
39
+ }
40
+
19
41
  export function parseServeOptions(args, env = process.env) {
20
42
  const options = {
21
43
  help: false,
@@ -119,6 +141,7 @@ export async function serve(context, args = []) {
119
141
  let child = null;
120
142
 
121
143
  try {
144
+ assertHostnameAllowed(hostname);
122
145
  let resolvedPort = null;
123
146
  if (options.port != null) {
124
147
  resolvedPort = await resolveServePort(options);
@@ -42,6 +42,12 @@ const DEFAULT_THRESHOLDS: AnomalyThresholds = { notable: 3, high: 4.5, severe: 6
42
42
  // Makes MAD a consistent estimator of stddev under normality.
43
43
  const MAD_TO_STDDEV = 0.6745;
44
44
 
45
+ // When the trailing window has zero spread (mad === 0) a true modified z-score
46
+ // is undefined/infinite. We still flag a large jump as severe, but report a
47
+ // finite sentinel z-score so the value round-trips through JSON (JSON.stringify
48
+ // serializes Infinity as null, which would silently drop the signal).
49
+ const FLAT_RUN_Z = 999;
50
+
45
51
  function median(values: number[]): number {
46
52
  if (values.length === 0) return 0;
47
53
  const sorted = values.slice().sort((a, b) => a - b);
@@ -81,7 +87,7 @@ function scoreValue(value: number, window: number[], thresholds: AnomalyThreshol
81
87
  }
82
88
  const flatRunLength = window.filter((entry) => entry === baseline).length;
83
89
  if (value > 2 * baseline && flatRunLength >= 3) {
84
- return { severity: "severe", baseline, zScore: Number.POSITIVE_INFINITY };
90
+ return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
85
91
  }
86
92
  return { severity: null, baseline, zScore: 0 };
87
93
  }
@@ -238,7 +238,6 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
238
238
  }
239
239
  : null;
240
240
  const resolvedPrice = aliasPrice ?? directPrice;
241
- const resolvedViaAlias = aliasPrice != null;
242
241
 
243
242
  const cost = calculateInteractionCost(
244
243
  {
@@ -252,6 +251,11 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
252
251
  resolvedPrice
253
252
  );
254
253
 
254
+ // Only treat the row as alias-resolved if the alias actually produced a
255
+ // cost. An alias pointing at a model that itself lacks complete pricing
256
+ // must not be labelled as estimated/alias-sourced with a null amount.
257
+ const resolvedViaAlias = aliasPrice != null && cost.amount != null;
258
+
255
259
  if (cost.status === "unknown") unknownCostInteractions += 1;
256
260
  const metadata = {
257
261
  ...existingMetadata,
@@ -1,3 +1,4 @@
1
+ import { sqlite } from "@/src/db/client";
1
2
  import { prepareCached } from "@/src/db/prepared";
2
3
  import { calculateInteractionCost, type PriceConfig } from "@/src/lib/cost";
3
4
 
@@ -115,9 +116,14 @@ export function backfillAlias(
115
116
  const stmt = prepareCached(
116
117
  "UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
117
118
  );
118
- for (const update of updates) {
119
- stmt.run(update.cost, update.id);
120
- }
119
+ // Atomic: either every matched interaction is backfilled or none are, so a
120
+ // mid-loop failure can't leave the cost column half-updated.
121
+ const writeAll = sqlite.transaction((rows: Array<{ id: string; cost: number }>) => {
122
+ for (const update of rows) {
123
+ stmt.run(update.cost, update.id);
124
+ }
125
+ });
126
+ writeAll(updates);
121
127
  }
122
128
 
123
129
  return {
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { expandHome } from "@/src/ingestion/discovery";
5
+
6
+ /**
7
+ * Containment for endpoints that read a caller-supplied file path (parser
8
+ * previews, import-profile previews).
9
+ *
10
+ * The dashboard's perimeter (see request-guard) already blocks remote and
11
+ * cross-site callers, so the only legitimate caller is the user's own browser.
12
+ * This module is defense-in-depth: even a same-origin request may only read
13
+ * files under directories that could plausibly hold AI usage logs — the user's
14
+ * home directory, the OS temp directory, and any explicitly configured import
15
+ * folders. That keeps the feature usable (CLI logs live under $HOME) while
16
+ * ensuring the dashboard can never be turned into a reader for `/etc/*`,
17
+ * `/root/*`, other users' home directories, or files reached via symlink
18
+ * escapes.
19
+ */
20
+
21
+ export type PathAccessErrorCode = "invalid" | "not_found" | "forbidden";
22
+
23
+ export class PathAccessError extends Error {
24
+ readonly code: PathAccessErrorCode;
25
+
26
+ constructor(code: PathAccessErrorCode, message: string) {
27
+ super(message);
28
+ this.name = "PathAccessError";
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /** HTTP status that best matches each failure mode. */
34
+ export function pathAccessStatus(code: PathAccessErrorCode): number {
35
+ switch (code) {
36
+ case "invalid":
37
+ return 400;
38
+ case "not_found":
39
+ return 404;
40
+ case "forbidden":
41
+ return 403;
42
+ }
43
+ }
44
+
45
+ async function realpathOrNull(target: string): Promise<string | null> {
46
+ try {
47
+ return await fs.realpath(target);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Resolve and de-duplicate the directories the dashboard may read from. */
54
+ export async function getAllowedReadRoots(extraRoots: string[] = []): Promise<string[]> {
55
+ const base = [os.homedir(), os.tmpdir(), ...extraRoots.map((root) => expandHome(root.trim()))];
56
+ const resolved = await Promise.all(
57
+ base.filter(Boolean).map((root) => realpathOrNull(path.resolve(root)))
58
+ );
59
+ return Array.from(new Set(resolved.filter((root): root is string => Boolean(root))));
60
+ }
61
+
62
+ function isWithinRoot(target: string, root: string): boolean {
63
+ if (target === root) return true;
64
+ const rel = path.relative(root, target);
65
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
66
+ }
67
+
68
+ /**
69
+ * Validate a caller-supplied path and return its fully resolved (symlink-free)
70
+ * form. Throws {@link PathAccessError} with a `code` that maps to an HTTP status
71
+ * via {@link pathAccessStatus}.
72
+ */
73
+ export async function resolveReadablePath(
74
+ input: unknown,
75
+ options: { extraRoots?: string[] } = {}
76
+ ): Promise<string> {
77
+ const expanded = typeof input === "string" ? expandHome(input.trim()) : "";
78
+ if (!expanded) {
79
+ throw new PathAccessError("invalid", "A file path is required.");
80
+ }
81
+ if (!path.isAbsolute(expanded)) {
82
+ throw new PathAccessError("invalid", "File path must be absolute.");
83
+ }
84
+
85
+ const real = await realpathOrNull(expanded);
86
+ if (!real) {
87
+ throw new PathAccessError("not_found", `File not found: ${expanded}`);
88
+ }
89
+
90
+ const roots = await getAllowedReadRoots(options.extraRoots);
91
+ if (!roots.some((root) => isWithinRoot(real, root))) {
92
+ throw new PathAccessError(
93
+ "forbidden",
94
+ "Path is outside the allowed import directories (home, temp, and configured folders)."
95
+ );
96
+ }
97
+
98
+ return real;
99
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Perimeter guard for the local dashboard's HTTP surface.
3
+ *
4
+ * The dashboard is unauthenticated by design (local-first, single user), so it
5
+ * relies entirely on *who can reach it* and *who is allowed to drive it*:
6
+ *
7
+ * - Host allowlist: rejects requests whose `Host` header is not a loopback
8
+ * name. This defeats DNS-rebinding attacks, where a malicious page rebinds
9
+ * its own domain to 127.0.0.1 to turn cross-origin reads into "same-origin"
10
+ * ones. A rebound request still carries the attacker's `Host`.
11
+ * - Cross-site write protection: rejects state-changing requests that a
12
+ * browser reports as cross-site (or that carry a non-loopback `Origin`).
13
+ * This defeats CSRF, where a malicious page silently POSTs to the dashboard.
14
+ *
15
+ * Non-browser clients (CLI, curl) send neither `Origin` nor `Sec-Fetch-*` and
16
+ * are allowed through — the threat model is a browser being weaponised by a
17
+ * third-party site, not the user's own tooling.
18
+ *
19
+ * This module is intentionally dependency-free so it can run in the Next.js
20
+ * middleware (edge) runtime and be unit-tested in isolation.
21
+ */
22
+
23
+ export type RequestGuardInput = {
24
+ method: string;
25
+ host: string | null | undefined;
26
+ origin?: string | null;
27
+ secFetchSite?: string | null;
28
+ };
29
+
30
+ export type RequestGuardEnv = {
31
+ TOKENTRACE_ALLOW_REMOTE?: string;
32
+ };
33
+
34
+ export type RequestGuardResult =
35
+ | { ok: true }
36
+ | { ok: false; status: number; error: string };
37
+
38
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
39
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
40
+
41
+ /** Extract the lowercased hostname from a `Host`/authority value, dropping any port. */
42
+ function hostnameOf(value: string): string {
43
+ const trimmed = value.trim().toLowerCase();
44
+ if (!trimmed) return "";
45
+ // Bracketed IPv6 literal, optionally with a port: [::1]:3030
46
+ if (trimmed.startsWith("[")) {
47
+ const end = trimmed.indexOf("]");
48
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
49
+ }
50
+ // Bare IPv6 (multiple colons) has no port suffix we can strip safely.
51
+ if (trimmed.split(":").length > 2) return trimmed;
52
+ const colon = trimmed.indexOf(":");
53
+ const host = colon === -1 ? trimmed : trimmed.slice(0, colon);
54
+ // Treat the FQDN trailing-dot form ("localhost.") as the bare name. This only
55
+ // ever loosens matching toward the canonical name, never across it.
56
+ return host.endsWith(".") ? host.slice(0, -1) : host;
57
+ }
58
+
59
+ function isLoopbackAuthority(value: string | null | undefined): boolean {
60
+ if (!value) return false;
61
+ return LOOPBACK_HOSTNAMES.has(hostnameOf(value));
62
+ }
63
+
64
+ function originIsLoopback(origin: string | null | undefined): boolean {
65
+ if (!origin) return false;
66
+ try {
67
+ return LOOPBACK_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function evaluateRequestGuard(
74
+ input: RequestGuardInput,
75
+ env: RequestGuardEnv = {}
76
+ ): RequestGuardResult {
77
+ const allowRemote = env.TOKENTRACE_ALLOW_REMOTE === "1";
78
+
79
+ if (!allowRemote && !isLoopbackAuthority(input.host)) {
80
+ return {
81
+ ok: false,
82
+ status: 421,
83
+ error:
84
+ "Request Host is not a recognised local address. TokenTrace only serves loopback hosts unless TOKENTRACE_ALLOW_REMOTE=1."
85
+ };
86
+ }
87
+
88
+ const method = input.method.toUpperCase();
89
+ if (SAFE_METHODS.has(method)) {
90
+ return { ok: true };
91
+ }
92
+
93
+ if (allowRemote) {
94
+ return { ok: true };
95
+ }
96
+
97
+ const secFetchSite = input.secFetchSite?.toLowerCase();
98
+ if (secFetchSite) {
99
+ if (secFetchSite === "same-origin" || secFetchSite === "none") {
100
+ return { ok: true };
101
+ }
102
+ return {
103
+ ok: false,
104
+ status: 403,
105
+ error: "Cross-site request blocked. TokenTrace only accepts same-origin writes."
106
+ };
107
+ }
108
+
109
+ // No Sec-Fetch-Site (older browser or non-browser client): fall back to Origin.
110
+ if (input.origin && !originIsLoopback(input.origin)) {
111
+ return {
112
+ ok: false,
113
+ status: 403,
114
+ error: "Cross-origin request blocked. TokenTrace only accepts same-origin writes."
115
+ };
116
+ }
117
+
118
+ return { ok: true };
119
+ }
@@ -10,7 +10,24 @@ function latestScanDate() {
10
10
  return row?.scanAt ? new Date(row.scanAt) : null;
11
11
  }
12
12
 
13
- export async function runDueScheduledScan(now = new Date()) {
13
+ type ScheduledScanOutcome = Awaited<ReturnType<typeof runScheduledScan>>;
14
+
15
+ // The dashboard fires runDueScheduledScan() on every overview page load. Two
16
+ // overlapping loads (a second tab, a quick refresh, prefetch) would otherwise
17
+ // both pass the due-check and both call runScan(), producing duplicate scan
18
+ // runs and wasted work. The Next.js server is a single process, so a
19
+ // module-scoped promise guard coalesces concurrent callers into one scan.
20
+ let inFlightScheduledScan: Promise<ScheduledScanOutcome> | null = null;
21
+
22
+ export function runDueScheduledScan(now = new Date()): Promise<ScheduledScanOutcome> {
23
+ if (inFlightScheduledScan) return inFlightScheduledScan;
24
+ inFlightScheduledScan = runScheduledScan(now).finally(() => {
25
+ inFlightScheduledScan = null;
26
+ });
27
+ return inFlightScheduledScan;
28
+ }
29
+
30
+ async function runScheduledScan(now: Date) {
14
31
  const settings = getAppSettings();
15
32
  if (!isScanDue(settings.scanSchedule, latestScanDate(), now)) {
16
33
  return {
@@ -47,7 +47,11 @@ const PRESET: readonly StructuredQueryRangePreset[] = ["today", "7d", "30d", "60
47
47
 
48
48
  function valueOf(arg: string, next: string | undefined): { value: string; consumedNext: boolean } {
49
49
  const eq = arg.indexOf("=");
50
- if (eq >= 0) return { value: arg.slice(eq + 1), consumedNext: false };
50
+ if (eq >= 0) {
51
+ const value = arg.slice(eq + 1);
52
+ if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
53
+ return { value, consumedNext: false };
54
+ }
51
55
  if (next == null || next.startsWith("-")) {
52
56
  throw new Error(`Missing value for ${arg}`);
53
57
  }
package/tsconfig.json CHANGED
@@ -38,7 +38,6 @@
38
38
  ".next/dev/types/**/*.ts"
39
39
  ],
40
40
  "exclude": [
41
- "node_modules",
42
- "tailwind.config.ts"
41
+ "node_modules"
43
42
  ]
44
43
  }