@ipv9/tokentracker-cli 0.39.43 → 0.39.45
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/README.md +18 -10
- package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
- package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
- package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
- package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
- package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
- package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
- package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
- package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
- package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
- package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/package.json +5 -3
- package/src/commands/doctor.js +8 -0
- package/src/commands/init.js +1 -1
- package/src/commands/sync.js +67 -0
- package/src/lib/doctor.js +227 -1
- package/src/lib/local-api.js +385 -113
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/process-list.js +91 -0
- package/src/lib/queue-compact.js +220 -0
- package/src/lib/rollout.js +81 -14
- package/src/lib/single-flight.js +59 -0
- package/src/lib/skills-manager.js +2 -2
- package/src/lib/transcript-suppression.js +133 -0
- package/src/lib/usage-limits.js +99 -22
- package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
- package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
- package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
- package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
package/src/lib/local-api.js
CHANGED
|
@@ -20,6 +20,83 @@ const AVATAR_PROXY_MAX_BYTES = 512 * 1024; // 512 KiB per image
|
|
|
20
20
|
const AVATAR_PROXY_MAX_ENTRIES = 64;
|
|
21
21
|
const avatarProxyCache = new Map();
|
|
22
22
|
|
|
23
|
+
// Sentinel for "a redirect left the allowlist", kept distinct from a network
|
|
24
|
+
// failure so the caller can answer 403 rather than a generic upstream error.
|
|
25
|
+
const AVATAR_REDIRECT_BLOCKED = Symbol("avatar-redirect-blocked");
|
|
26
|
+
const AVATAR_MAX_REDIRECTS = 3;
|
|
27
|
+
|
|
28
|
+
// Reads at most `maxBytes`, and STOPS READING at the limit. Returns null when the
|
|
29
|
+
// response is over it.
|
|
30
|
+
//
|
|
31
|
+
// `AVATAR_PROXY_MAX_BYTES` read like a download cap and was not one: the whole
|
|
32
|
+
// upstream body was buffered with `arrayBuffer()` first, and the constant then
|
|
33
|
+
// only decided whether the result entered the cache. An oversized response was
|
|
34
|
+
// still read into memory in full and still written to the client, so an
|
|
35
|
+
// allowlisted host serving a very large image/* drove loopback-server memory
|
|
36
|
+
// with no ceiling.
|
|
37
|
+
//
|
|
38
|
+
// Two gates, because either alone is soft: `content-length` is a claim the peer
|
|
39
|
+
// makes and can lie about or omit, and counting bytes without it means the
|
|
40
|
+
// transfer has already started. Check the claim, then count anyway.
|
|
41
|
+
async function readCappedAvatarBody(response, maxBytes) {
|
|
42
|
+
const declared = Number(response.headers.get("content-length"));
|
|
43
|
+
if (Number.isFinite(declared) && declared > maxBytes) return null;
|
|
44
|
+
// A HEAD response, or a stub without a stream. `arrayBuffer()` is bounded here
|
|
45
|
+
// by the content-length check above and by the length check below.
|
|
46
|
+
if (!response.body) {
|
|
47
|
+
const buffered = Buffer.from(await response.arrayBuffer());
|
|
48
|
+
return buffered.length > maxBytes ? null : buffered;
|
|
49
|
+
}
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let total = 0;
|
|
52
|
+
for await (const chunk of response.body) {
|
|
53
|
+
total += chunk.length;
|
|
54
|
+
// Returning from a for-await calls the iterator's return(), which cancels
|
|
55
|
+
// the stream — the download stops here rather than running to completion.
|
|
56
|
+
if (total > maxBytes) return null;
|
|
57
|
+
chunks.push(Buffer.from(chunk));
|
|
58
|
+
}
|
|
59
|
+
return Buffer.concat(chunks);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One check for the URL the caller asked for AND for every redirect hop, so the
|
|
63
|
+
// two can't drift apart. Port is part of the address: `https://gravatar.com:8443/`
|
|
64
|
+
// shares the hostname but is a different service, so leaving the port free turns
|
|
65
|
+
// this proxy into a port prober against every allowlisted host. An empty `port`
|
|
66
|
+
// is the scheme default (443 / 80) — the only one permitted.
|
|
67
|
+
function isAllowedAvatarTarget(url, allowlist) {
|
|
68
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
69
|
+
if (url.port !== "") return false;
|
|
70
|
+
return allowlist.some(
|
|
71
|
+
(host) => url.hostname === host || url.hostname.endsWith(`.${host}`),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The avatar allowlist is checked once, against the URL the caller asked for.
|
|
76
|
+
// `fetch(..., { redirect: "follow" })` then goes wherever the response points and
|
|
77
|
+
// validates nothing further — so an allowlisted CDN issuing a redirect, or anyone
|
|
78
|
+
// able to place one there, turns this loopback server into a way to reach
|
|
79
|
+
// 169.254.169.254, another service on 127.0.0.1, or any internal address.
|
|
80
|
+
// Follow by hand and re-check the whole address at every hop.
|
|
81
|
+
async function fetchAvatarFollowingAllowlist(url, options, allowlist, fetchImpl = fetch) {
|
|
82
|
+
let current = url;
|
|
83
|
+
for (let hop = 0; hop <= AVATAR_MAX_REDIRECTS; hop += 1) {
|
|
84
|
+
const response = await fetchImpl(current, { ...options, redirect: "manual" });
|
|
85
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
86
|
+
const location = response.headers.get("location");
|
|
87
|
+
if (!location) return response;
|
|
88
|
+
let next;
|
|
89
|
+
try {
|
|
90
|
+
next = new URL(location, current);
|
|
91
|
+
} catch {
|
|
92
|
+
return AVATAR_REDIRECT_BLOCKED;
|
|
93
|
+
}
|
|
94
|
+
if (!isAllowedAvatarTarget(next, allowlist)) return AVATAR_REDIRECT_BLOCKED;
|
|
95
|
+
current = next.toString();
|
|
96
|
+
}
|
|
97
|
+
return AVATAR_REDIRECT_BLOCKED;
|
|
98
|
+
}
|
|
99
|
+
|
|
23
100
|
// ---------------------------------------------------------------------------
|
|
24
101
|
// Per-model pricing — delegated to src/lib/pricing/
|
|
25
102
|
// - CURATED overrides (kiro-*, hy3-*, composer-*, kimi-for-coding, etc.)
|
|
@@ -67,7 +144,12 @@ function readProjectQueueData(projectQueuePath) {
|
|
|
67
144
|
for (const line of lines) {
|
|
68
145
|
try {
|
|
69
146
|
const row = JSON.parse(line);
|
|
70
|
-
|
|
147
|
+
// Model is part of the identity now. A legacy row has no `model` and gets
|
|
148
|
+
// the same "unknown" slot the state migration gives its stranded bucket,
|
|
149
|
+
// so the two share one key instead of both surviving and double-counting.
|
|
150
|
+
const key =
|
|
151
|
+
`${row.project_key || ""}|${row.source || ""}` +
|
|
152
|
+
`|${row.model || "unknown"}|${row.hour_start || ""}`;
|
|
71
153
|
seen.set(key, row);
|
|
72
154
|
} catch {
|
|
73
155
|
// skip malformed
|
|
@@ -661,6 +743,40 @@ function json(res, data, status) {
|
|
|
661
743
|
const IP_CHECK_PROXY_PREFIX = "/proxy/ipcheck";
|
|
662
744
|
const IP_CHECK_TARGET = "https://ip.net.coffee";
|
|
663
745
|
|
|
746
|
+
const LOCAL_API_METHODS = new Map([
|
|
747
|
+
["/api/local-auth", ["GET"]],
|
|
748
|
+
["/proxy/ipcheck", ["GET", "HEAD"]],
|
|
749
|
+
["/api/avatar-proxy", ["GET", "HEAD"]],
|
|
750
|
+
["/functions/tokentracker-local-sync", ["POST"]],
|
|
751
|
+
["/functions/tokentracker-wrapped", ["GET"]],
|
|
752
|
+
["/functions/tokentracker-usage-summary", ["GET"]],
|
|
753
|
+
["/functions/tokentracker-usage-daily", ["GET"]],
|
|
754
|
+
["/functions/tokentracker-usage-heatmap", ["GET"]],
|
|
755
|
+
["/functions/tokentracker-usage-model-breakdown", ["GET"]],
|
|
756
|
+
["/functions/tokentracker-usage-category-breakdown", ["GET"]],
|
|
757
|
+
["/functions/tokentracker-project-usage-summary", ["GET"]],
|
|
758
|
+
["/functions/tokentracker-user-status", ["GET"]],
|
|
759
|
+
["/functions/tokentracker-usage-hourly", ["GET"]],
|
|
760
|
+
["/functions/tokentracker-usage-monthly", ["GET"]],
|
|
761
|
+
["/functions/tokentracker-skills", ["GET", "POST"]],
|
|
762
|
+
["/functions/tokentracker-usage-limits", ["GET"]],
|
|
763
|
+
["/functions/tokentracker-ingest-health", ["GET"]],
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
// Preserve the established JSON shape for the two mutation-capable endpoints
|
|
767
|
+
// that already returned `{ ok: false, error }` for unsupported methods.
|
|
768
|
+
const LOCAL_API_OK_FALSE_METHOD_ERRORS = new Set([
|
|
769
|
+
"/functions/tokentracker-local-sync",
|
|
770
|
+
"/functions/tokentracker-skills",
|
|
771
|
+
]);
|
|
772
|
+
|
|
773
|
+
function allowedMethodsForLocalApiPath(pathname) {
|
|
774
|
+
return LOCAL_API_METHODS.get(pathname)
|
|
775
|
+
|| (pathname.startsWith(`${IP_CHECK_PROXY_PREFIX}/`)
|
|
776
|
+
? LOCAL_API_METHODS.get(IP_CHECK_PROXY_PREFIX)
|
|
777
|
+
: null);
|
|
778
|
+
}
|
|
779
|
+
|
|
664
780
|
// HTTP hop-by-hop headers (RFC 7230 §6.1) plus headers undici/fetch manages
|
|
665
781
|
// internally. Forwarding any of these to `fetch(...)` either silently breaks
|
|
666
782
|
// the request (host being wrong) or, on stricter undici versions like the
|
|
@@ -687,6 +803,182 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
687
803
|
// Main handler factory
|
|
688
804
|
// ---------------------------------------------------------------------------
|
|
689
805
|
|
|
806
|
+
// Project usage, filtered the way every other card is filtered.
|
|
807
|
+
//
|
|
808
|
+
// Extracted from the request handler so the aggregation can be tested without
|
|
809
|
+
// standing up a server — and because the handler block reached 129 lines once it
|
|
810
|
+
// stopped ignoring the query string.
|
|
811
|
+
// The client has always sent from/to/source/limit/timeZone; the project handler
|
|
812
|
+
// read none of them and aggregated the whole file unconditionally. Pick "24h"
|
|
813
|
+
// and every other card narrowed while Projects kept showing all-time totals,
|
|
814
|
+
// with nothing on screen saying so — someone comparing "this week's spend by
|
|
815
|
+
// repo" was reading lifetime numbers.
|
|
816
|
+
//
|
|
817
|
+
// Pure surfacing: hour_start is already on every row. Same shape as the other
|
|
818
|
+
// range-filtered handlers (getTimeZoneContext + rowDayKey), so a day boundary
|
|
819
|
+
// means the same thing on every card.
|
|
820
|
+
function buildProjectFilters(url) {
|
|
821
|
+
const from = url.searchParams.get("from") || "";
|
|
822
|
+
const to = url.searchParams.get("to") || "";
|
|
823
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
824
|
+
const requestedSource = (url.searchParams.get("source") || "").trim().toLowerCase();
|
|
825
|
+
const rawLimit = Number(url.searchParams.get("limit"));
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
limit: Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : null,
|
|
829
|
+
// An absent bound means "no bound". The other handlers compare against ""
|
|
830
|
+
// directly, which is safe there because the client always sends both; here
|
|
831
|
+
// it does not, and `day <= ""` would silently return nothing.
|
|
832
|
+
inWindow(row) {
|
|
833
|
+
if (!from && !to) return true;
|
|
834
|
+
if (!row.hour_start) return false;
|
|
835
|
+
const day = rowDayKey(row, timeZoneContext);
|
|
836
|
+
return (!from || day >= from) && (!to || day <= to);
|
|
837
|
+
},
|
|
838
|
+
matchesSource(row) {
|
|
839
|
+
return !requestedSource || String(row.source || "").toLowerCase() === requestedSource;
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Which sources contributed usage in this window but carry no project
|
|
845
|
+
// attribution at all. Without naming them, their absence reads as "that tool
|
|
846
|
+
// cost nothing here" — the panel under-reports and looks complete while doing
|
|
847
|
+
// it. `projectBucketsQueued` exists in 7 of the parsers; Cursor, Copilot, Zed,
|
|
848
|
+
// Goose and Kiro have no per-repo story at all.
|
|
849
|
+
function findUnattributedSources({ queuePath, allProjectRows, inWindow, matchesSource }) {
|
|
850
|
+
const attributed = new Set(
|
|
851
|
+
allProjectRows.filter(inWindow).map((row) => String(row.source || "").toLowerCase()),
|
|
852
|
+
);
|
|
853
|
+
return [
|
|
854
|
+
...new Set(
|
|
855
|
+
readQueueData(queuePath)
|
|
856
|
+
.filter((row) => inWindow(row) && matchesSource(row))
|
|
857
|
+
.map((row) => String(row.source || "").toLowerCase())
|
|
858
|
+
.filter((src) => src && !attributed.has(src)),
|
|
859
|
+
),
|
|
860
|
+
].sort();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// No project-attributed rows at all (project attribution never synced, or only
|
|
864
|
+
// non-project-capable CLIs used). Falls back to per-source totals so the panel
|
|
865
|
+
// is not simply empty. This path once ALSO ran for the non-empty case and
|
|
866
|
+
// produced fiction — "session-file count x total tokens" gave every short-and-
|
|
867
|
+
// hot project the same weight as every long-and-cold one. Empty case only.
|
|
868
|
+
function aggregateBySource(queuePath, inWindow, matchesSource) {
|
|
869
|
+
const bySrc = new Map();
|
|
870
|
+
for (const row of readQueueData(queuePath)) {
|
|
871
|
+
if (!inWindow(row) || !matchesSource(row)) continue;
|
|
872
|
+
const src = row.source || "unknown";
|
|
873
|
+
if (!bySrc.has(src)) {
|
|
874
|
+
bySrc.set(src, {
|
|
875
|
+
project_key: src,
|
|
876
|
+
// Synthetic source-only row: project_ref stays empty rather than
|
|
877
|
+
// fabricating `https://${src}.ai`, which resolves to unrelated domains
|
|
878
|
+
// (codex.ai, cursor.ai) and was sent to the dashboard as a clickable
|
|
879
|
+
// href before v0.11.1.
|
|
880
|
+
project_ref: "",
|
|
881
|
+
total_tokens: 0,
|
|
882
|
+
billable_total_tokens: 0,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
bySrc.get(src).total_tokens += row.total_tokens || 0;
|
|
886
|
+
bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
|
|
887
|
+
}
|
|
888
|
+
return bySrc;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Per-repo cost, which the data model could not express until project rows
|
|
892
|
+
// carried a model. computeRowCost is the same function the rest of the product
|
|
893
|
+
// prices with, so a repo total and a model total cannot disagree.
|
|
894
|
+
//
|
|
895
|
+
// A row with no model prices at 0 and is counted as UNATTRIBUTED rather than
|
|
896
|
+
// folded silently into the total — that is the #94 tier doing exactly what it
|
|
897
|
+
// was added for. "Recorded before we recorded models" renders honestly instead
|
|
898
|
+
// of as a confident $0.
|
|
899
|
+
function aggregateByProject(rows) {
|
|
900
|
+
const byProject = new Map();
|
|
901
|
+
for (const row of rows) {
|
|
902
|
+
const key = row.project_key || "unknown";
|
|
903
|
+
if (!byProject.has(key)) {
|
|
904
|
+
byProject.set(key, {
|
|
905
|
+
project_key: key,
|
|
906
|
+
project_ref: row.project_ref || key,
|
|
907
|
+
total_tokens: 0,
|
|
908
|
+
billable_total_tokens: 0,
|
|
909
|
+
total_cost_usd: 0,
|
|
910
|
+
unattributed_tokens: 0,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
const agg = byProject.get(key);
|
|
914
|
+
const tokens = Number(row.total_tokens || 0);
|
|
915
|
+
agg.total_tokens += tokens;
|
|
916
|
+
agg.billable_total_tokens += tokens;
|
|
917
|
+
const model = typeof row.model === "string" ? row.model.trim() : "";
|
|
918
|
+
if (!model || model === "unknown") {
|
|
919
|
+
agg.unattributed_tokens += tokens;
|
|
920
|
+
} else {
|
|
921
|
+
agg.total_cost_usd += computeRowCost(row);
|
|
922
|
+
}
|
|
923
|
+
if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
|
|
924
|
+
}
|
|
925
|
+
return byProject;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Rows -> ranked entries, in the shape the panel already renders (token counts
|
|
929
|
+
// as strings). Split out so buildProjectUsageSummary stays under the size rule.
|
|
930
|
+
function rankProjectEntries(byKey) {
|
|
931
|
+
return Array.from(byKey.values())
|
|
932
|
+
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
933
|
+
.map((e) => ({
|
|
934
|
+
...e,
|
|
935
|
+
total_tokens: String(e.total_tokens),
|
|
936
|
+
billable_total_tokens: String(e.billable_total_tokens),
|
|
937
|
+
// Strings for the same reason the token counts are: the panel formats
|
|
938
|
+
// them, and a float in JSON invites a rounding difference between the two
|
|
939
|
+
// sides of the wire.
|
|
940
|
+
total_cost_usd: (e.total_cost_usd ?? 0).toFixed(6),
|
|
941
|
+
unattributed_tokens: String(e.unattributed_tokens ?? 0),
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function buildProjectUsageSummary({ url, queuePath, projectQueuePath }) {
|
|
946
|
+
// Use the per-project bucket log that rollout.js emits — it already
|
|
947
|
+
// carries the actual tokens attributed to each (project_key, source,
|
|
948
|
+
// hour_start). Falling back to "session-file count × total tokens"
|
|
949
|
+
// (the old behavior) produced pure fiction: every short-and-hot
|
|
950
|
+
// project got the same weight as every long-and-cold one.
|
|
951
|
+
const allProjectRows = readProjectQueueData(projectQueuePath);
|
|
952
|
+
|
|
953
|
+
const { inWindow, matchesSource, limit } = buildProjectFilters(url);
|
|
954
|
+
|
|
955
|
+
const projectRows = allProjectRows.filter((row) => inWindow(row) && matchesSource(row));
|
|
956
|
+
|
|
957
|
+
const unattributedSources = findUnattributedSources({
|
|
958
|
+
queuePath,
|
|
959
|
+
allProjectRows,
|
|
960
|
+
inWindow,
|
|
961
|
+
matchesSource,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const byProject = aggregateByProject(projectRows);
|
|
965
|
+
|
|
966
|
+
let entries =
|
|
967
|
+
byProject.size === 0
|
|
968
|
+
? rankProjectEntries(aggregateBySource(queuePath, inWindow, matchesSource))
|
|
969
|
+
: rankProjectEntries(byProject);
|
|
970
|
+
|
|
971
|
+
if (limit != null) entries = entries.slice(0, limit);
|
|
972
|
+
|
|
973
|
+
return {
|
|
974
|
+
generated_at: new Date().toISOString(),
|
|
975
|
+
entries,
|
|
976
|
+
// Named so the panel can say what it cannot account for, rather than
|
|
977
|
+
// letting a missing tool read as a tool that cost nothing.
|
|
978
|
+
unattributed_sources: unattributedSources,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
690
982
|
function createLocalApiHandler({ queuePath }) {
|
|
691
983
|
const qp = queuePath || resolveQueuePath();
|
|
692
984
|
|
|
@@ -703,12 +995,21 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
703
995
|
|
|
704
996
|
return async function handleLocalApi(req, res, url) {
|
|
705
997
|
const p = url.pathname;
|
|
998
|
+
const allowedMethods = allowedMethodsForLocalApiPath(p);
|
|
999
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1000
|
+
if (allowedMethods && !allowedMethods.includes(method)) {
|
|
1001
|
+
const error = "Method Not Allowed";
|
|
1002
|
+
res.writeHead(405, {
|
|
1003
|
+
"Content-Type": "application/json",
|
|
1004
|
+
Allow: allowedMethods.join(", "),
|
|
1005
|
+
});
|
|
1006
|
+
res.end(JSON.stringify(
|
|
1007
|
+
LOCAL_API_OK_FALSE_METHOD_ERRORS.has(p) ? { ok: false, error } : { error },
|
|
1008
|
+
));
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
706
1011
|
|
|
707
1012
|
if (p === "/api/local-auth") {
|
|
708
|
-
if (String(req.method || "GET").toUpperCase() !== "GET") {
|
|
709
|
-
json(res, { error: "Method Not Allowed" }, 405);
|
|
710
|
-
return true;
|
|
711
|
-
}
|
|
712
1013
|
res.writeHead(200, {
|
|
713
1014
|
"Content-Type": "application/json",
|
|
714
1015
|
"Cache-Control": "no-store",
|
|
@@ -724,10 +1025,6 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
724
1025
|
// (exfiltrate dashboard cookies, anonymously POST through user IP).
|
|
725
1026
|
if (p.startsWith(`${IP_CHECK_PROXY_PREFIX}/`) || p === IP_CHECK_PROXY_PREFIX) {
|
|
726
1027
|
const method = String(req.method || "GET").toUpperCase();
|
|
727
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
728
|
-
json(res, { error: "Method Not Allowed" }, 405);
|
|
729
|
-
return true;
|
|
730
|
-
}
|
|
731
1028
|
const targetPath = p === IP_CHECK_PROXY_PREFIX
|
|
732
1029
|
? "/"
|
|
733
1030
|
: p.slice(IP_CHECK_PROXY_PREFIX.length) || "/";
|
|
@@ -799,10 +1096,6 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
799
1096
|
// proxy); strip cookies/auth; small in-memory cache.
|
|
800
1097
|
if (p === "/api/avatar-proxy") {
|
|
801
1098
|
const method = String(req.method || "GET").toUpperCase();
|
|
802
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
803
|
-
json(res, { error: "Method Not Allowed" }, 405);
|
|
804
|
-
return true;
|
|
805
|
-
}
|
|
806
1099
|
const target = url.searchParams.get("url");
|
|
807
1100
|
if (!target) {
|
|
808
1101
|
json(res, { error: "Missing url" }, 400);
|
|
@@ -833,11 +1126,8 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
833
1126
|
"abs.twimg.com",
|
|
834
1127
|
"api.dicebear.com",
|
|
835
1128
|
];
|
|
836
|
-
|
|
837
|
-
(
|
|
838
|
-
);
|
|
839
|
-
if (!hostOk) {
|
|
840
|
-
json(res, { error: "Host not allowed" }, 403);
|
|
1129
|
+
if (!isAllowedAvatarTarget(parsed, AVATAR_HOST_ALLOWLIST)) {
|
|
1130
|
+
json(res, { error: "Address not allowed" }, 403);
|
|
841
1131
|
return true;
|
|
842
1132
|
}
|
|
843
1133
|
|
|
@@ -855,15 +1145,22 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
855
1145
|
}
|
|
856
1146
|
|
|
857
1147
|
try {
|
|
858
|
-
const upstream = await
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1148
|
+
const upstream = await fetchAvatarFollowingAllowlist(
|
|
1149
|
+
cacheKey,
|
|
1150
|
+
{
|
|
1151
|
+
method,
|
|
1152
|
+
headers: {
|
|
1153
|
+
accept: req.headers["accept"] || "image/*",
|
|
1154
|
+
"accept-language": req.headers["accept-language"] || "en",
|
|
1155
|
+
"user-agent": "TokenTracker/AvatarProxy",
|
|
1156
|
+
},
|
|
865
1157
|
},
|
|
866
|
-
|
|
1158
|
+
AVATAR_HOST_ALLOWLIST,
|
|
1159
|
+
);
|
|
1160
|
+
if (upstream === AVATAR_REDIRECT_BLOCKED) {
|
|
1161
|
+
json(res, { error: "Redirect target not allowed" }, 403);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
867
1164
|
if (!upstream.ok) {
|
|
868
1165
|
json(res, { error: `Upstream ${upstream.status}` }, upstream.status);
|
|
869
1166
|
return true;
|
|
@@ -873,15 +1170,21 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
873
1170
|
json(res, { error: "Not an image" }, 415);
|
|
874
1171
|
return true;
|
|
875
1172
|
}
|
|
876
|
-
const body =
|
|
877
|
-
if (body
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1173
|
+
const body = await readCappedAvatarBody(upstream, AVATAR_PROXY_MAX_BYTES);
|
|
1174
|
+
if (body === null) {
|
|
1175
|
+
// Refused rather than served-but-not-cached, which is what the old
|
|
1176
|
+
// length check amounted to. An avatar this size is not an avatar, and
|
|
1177
|
+
// the dashboard falls back to its local icon on a failed image load.
|
|
1178
|
+
json(res, { error: "Avatar too large" }, 413);
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
// Within the cap by construction, so caching is unconditional.
|
|
1182
|
+
// Simple LRU: drop oldest if over capacity.
|
|
1183
|
+
if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
|
|
1184
|
+
const oldestKey = avatarProxyCache.keys().next().value;
|
|
1185
|
+
if (oldestKey) avatarProxyCache.delete(oldestKey);
|
|
884
1186
|
}
|
|
1187
|
+
avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
|
|
885
1188
|
res.writeHead(200, {
|
|
886
1189
|
"Content-Type": contentType,
|
|
887
1190
|
"Cache-Control": "public, max-age=3600",
|
|
@@ -896,10 +1199,6 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
896
1199
|
|
|
897
1200
|
// --- local-sync (POST) ---
|
|
898
1201
|
if (p === "/functions/tokentracker-local-sync") {
|
|
899
|
-
if (String(req.method || "GET").toUpperCase() !== "POST") {
|
|
900
|
-
json(res, { ok: false, error: "Method Not Allowed" }, 405);
|
|
901
|
-
return true;
|
|
902
|
-
}
|
|
903
1202
|
if (!isAuthorizedLocalMutation(req)) {
|
|
904
1203
|
json(res, { ok: false, error: "Unauthorized" }, 401);
|
|
905
1204
|
return true;
|
|
@@ -1230,78 +1529,14 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1230
1529
|
|
|
1231
1530
|
// --- project-usage-summary ---
|
|
1232
1531
|
if (p === "/functions/tokentracker-project-usage-summary") {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
"project.queue.jsonl",
|
|
1532
|
+
json(
|
|
1533
|
+
res,
|
|
1534
|
+
buildProjectUsageSummary({
|
|
1535
|
+
url,
|
|
1536
|
+
queuePath: qp,
|
|
1537
|
+
projectQueuePath: path.join(path.dirname(qp), "project.queue.jsonl"),
|
|
1538
|
+
}),
|
|
1241
1539
|
);
|
|
1242
|
-
const projectRows = readProjectQueueData(projectQueuePath);
|
|
1243
|
-
|
|
1244
|
-
const byProject = new Map();
|
|
1245
|
-
for (const row of projectRows) {
|
|
1246
|
-
const key = row.project_key || "unknown";
|
|
1247
|
-
if (!byProject.has(key)) {
|
|
1248
|
-
byProject.set(key, {
|
|
1249
|
-
project_key: key,
|
|
1250
|
-
project_ref: row.project_ref || key,
|
|
1251
|
-
total_tokens: 0,
|
|
1252
|
-
billable_total_tokens: 0,
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
const agg = byProject.get(key);
|
|
1256
|
-
agg.total_tokens += Number(row.total_tokens || 0);
|
|
1257
|
-
agg.billable_total_tokens += Number(row.total_tokens || 0);
|
|
1258
|
-
if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// If no project-attributed rows exist yet (user hasn't synced project
|
|
1262
|
-
// attribution, or never used a project-capable CLI), fall back to
|
|
1263
|
-
// per-source aggregation over the main queue so the panel isn't
|
|
1264
|
-
// totally empty. This path used to also exist for the non-empty case
|
|
1265
|
-
// and produce wrong numbers; keep it only as the empty fallback.
|
|
1266
|
-
let entries;
|
|
1267
|
-
if (byProject.size === 0) {
|
|
1268
|
-
const rows = readQueueData(qp);
|
|
1269
|
-
const bySrc = new Map();
|
|
1270
|
-
for (const row of rows) {
|
|
1271
|
-
const src = row.source || "unknown";
|
|
1272
|
-
if (!bySrc.has(src)) {
|
|
1273
|
-
bySrc.set(src, {
|
|
1274
|
-
project_key: src,
|
|
1275
|
-
// Synthetic source-only row: leave project_ref empty rather than
|
|
1276
|
-
// fabricating `https://${src}.ai`, which resolves to unrelated
|
|
1277
|
-
// domains (e.g. codex.ai, cursor.ai) and was sent to the
|
|
1278
|
-
// dashboard as a clickable href before v0.11.1 / this commit.
|
|
1279
|
-
project_ref: "",
|
|
1280
|
-
total_tokens: 0,
|
|
1281
|
-
billable_total_tokens: 0,
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
bySrc.get(src).total_tokens += row.total_tokens || 0;
|
|
1285
|
-
bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
|
|
1286
|
-
}
|
|
1287
|
-
entries = Array.from(bySrc.values())
|
|
1288
|
-
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1289
|
-
.map((e) => ({
|
|
1290
|
-
...e,
|
|
1291
|
-
total_tokens: String(e.total_tokens),
|
|
1292
|
-
billable_total_tokens: String(e.billable_total_tokens),
|
|
1293
|
-
}));
|
|
1294
|
-
} else {
|
|
1295
|
-
entries = Array.from(byProject.values())
|
|
1296
|
-
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1297
|
-
.map((e) => ({
|
|
1298
|
-
...e,
|
|
1299
|
-
total_tokens: String(e.total_tokens),
|
|
1300
|
-
billable_total_tokens: String(e.billable_total_tokens),
|
|
1301
|
-
}));
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
json(res, { generated_at: new Date().toISOString(), entries });
|
|
1305
1540
|
return true;
|
|
1306
1541
|
}
|
|
1307
1542
|
|
|
@@ -1502,7 +1737,6 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1502
1737
|
return true;
|
|
1503
1738
|
}
|
|
1504
1739
|
|
|
1505
|
-
json(res, { ok: false, error: "Method Not Allowed" }, 405);
|
|
1506
1740
|
} catch (e) {
|
|
1507
1741
|
json(res, { ok: false, error: e?.message || "Unknown skills error" }, 500);
|
|
1508
1742
|
}
|
|
@@ -1511,10 +1745,9 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1511
1745
|
|
|
1512
1746
|
// --- usage-limits ---
|
|
1513
1747
|
if (p === "/functions/tokentracker-usage-limits") {
|
|
1514
|
-
const { getUsageLimits, resetUsageLimitsCache } = require("./usage-limits");
|
|
1748
|
+
const { getUsageLimits, isForcedRefresh, resetUsageLimitsCache } = require("./usage-limits");
|
|
1515
1749
|
try {
|
|
1516
|
-
|
|
1517
|
-
if (forceRefresh === "1" || forceRefresh === "true") {
|
|
1750
|
+
if (isForcedRefresh(url.searchParams.get("refresh"))) {
|
|
1518
1751
|
resetUsageLimitsCache();
|
|
1519
1752
|
}
|
|
1520
1753
|
const data = await getUsageLimits({
|
|
@@ -1529,12 +1762,51 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1529
1762
|
return true;
|
|
1530
1763
|
}
|
|
1531
1764
|
|
|
1765
|
+
// --- ingest-health ---
|
|
1766
|
+
// Reports collection problems that make a source under-report without any
|
|
1767
|
+
// error: right now, Claude CLI sessions started with
|
|
1768
|
+
// `--no-session-persistence`, which write no transcript for the parser to
|
|
1769
|
+
// read. Served from the module's short TTL cache so that a dashboard poll
|
|
1770
|
+
// cannot turn into a process spawn per request.
|
|
1771
|
+
//
|
|
1772
|
+
// The payload is deliberately narrow — a count, model ids, and a coarse
|
|
1773
|
+
// reason. No pid, argv, or environment value is returned, because this
|
|
1774
|
+
// endpoint answers unauthenticated loopback GETs.
|
|
1775
|
+
if (p === "/functions/tokentracker-ingest-health") {
|
|
1776
|
+
const { detectTranscriptSuppression } = require("./transcript-suppression");
|
|
1777
|
+
try {
|
|
1778
|
+
const suppression = detectTranscriptSuppression({ platform: process.platform });
|
|
1779
|
+
json(res, {
|
|
1780
|
+
transcript_suppressed: {
|
|
1781
|
+
supported: suppression.supported,
|
|
1782
|
+
checked: suppression.checked,
|
|
1783
|
+
count: suppression.count,
|
|
1784
|
+
models: suppression.models,
|
|
1785
|
+
reason: suppression.reason,
|
|
1786
|
+
},
|
|
1787
|
+
checked_at: suppression.checked_at,
|
|
1788
|
+
});
|
|
1789
|
+
} catch (e) {
|
|
1790
|
+
json(res, { error: e?.message || "Unknown error" }, 500);
|
|
1791
|
+
}
|
|
1792
|
+
return true;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1532
1795
|
return false;
|
|
1533
1796
|
};
|
|
1534
1797
|
}
|
|
1535
1798
|
|
|
1536
1799
|
module.exports = {
|
|
1800
|
+
// Exported for tests: the aggregation runs without a server.
|
|
1801
|
+
buildProjectUsageSummary,
|
|
1537
1802
|
createLocalApiHandler,
|
|
1803
|
+
// Exported so the redirect allowlist can be tested directly: the SSRF this
|
|
1804
|
+
// closes is only observable across a redirect hop, which no handler-level
|
|
1805
|
+
// test reaches.
|
|
1806
|
+
fetchAvatarFollowingAllowlist,
|
|
1807
|
+
readCappedAvatarBody,
|
|
1808
|
+
isAllowedAvatarTarget,
|
|
1809
|
+
AVATAR_REDIRECT_BLOCKED,
|
|
1538
1810
|
resolveQueuePath,
|
|
1539
1811
|
// Exported for cross-consumer tests (pricing + native contract lock).
|
|
1540
1812
|
MODEL_PRICING,
|