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 +60 -0
- package/app/api/import-profile-preview/route.ts +16 -1
- package/app/api/parser-debug/preview/route.ts +17 -3
- package/dist/runtime/anomalies.mjs +3 -2
- package/dist/runtime/db-seed.mjs +1 -1
- package/dist/runtime/pricing-refresh.mjs +1 -1
- package/dist/runtime/query.mjs +13 -2
- package/dist/runtime/repair.mjs +31 -9
- package/dist/runtime/reset.mjs +1 -1
- package/dist/runtime/scan.mjs +1 -1
- package/next.config.mjs +38 -1
- package/package.json +1 -1
- package/scripts/query.ts +12 -1
- package/scripts/repair.ts +19 -6
- package/server.json +2 -2
- package/src/cli/serve.d.ts +32 -0
- package/src/cli/serve.js +23 -0
- package/src/lib/anomaly-detection.ts +7 -1
- package/src/lib/cost-recalculation.ts +5 -1
- package/src/lib/model-aliases/backfill.ts +9 -3
- package/src/lib/path-access.ts +99 -0
- package/src/lib/request-guard.ts +119 -0
- package/src/lib/scheduled-scan.ts +18 -1
- package/src/lib/structured-query-cli.ts +5 -1
- package/tsconfig.json +1 -2
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:
|
|
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
|
|
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 (!
|
|
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: ${
|
|
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:
|
|
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 }
|
package/dist/runtime/db-seed.mjs
CHANGED
|
@@ -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,
|
package/dist/runtime/query.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
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 {
|
package/dist/runtime/repair.mjs
CHANGED
|
@@ -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
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/runtime/reset.mjs
CHANGED
|
@@ -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,
|
package/dist/runtime/scan.mjs
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
11
|
+
"version": "0.19.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "tokentrace",
|
|
16
|
-
"version": "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:
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
}
|