@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.
Files changed (43) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +8 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +227 -1
  31. package/src/lib/local-api.js +385 -113
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/process-list.js +91 -0
  34. package/src/lib/queue-compact.js +220 -0
  35. package/src/lib/rollout.js +81 -14
  36. package/src/lib/single-flight.js +59 -0
  37. package/src/lib/skills-manager.js +2 -2
  38. package/src/lib/transcript-suppression.js +133 -0
  39. package/src/lib/usage-limits.js +99 -22
  40. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  41. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  42. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  43. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
@@ -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
- const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`;
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
- const hostOk = AVATAR_HOST_ALLOWLIST.some(
837
- (h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
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 fetch(cacheKey, {
859
- method,
860
- redirect: "follow",
861
- headers: {
862
- accept: req.headers["accept"] || "image/*",
863
- "accept-language": req.headers["accept-language"] || "en",
864
- "user-agent": "TokenTracker/AvatarProxy",
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 = Buffer.from(await upstream.arrayBuffer());
877
- if (body.length <= AVATAR_PROXY_MAX_BYTES) {
878
- // Simple LRU: drop oldest if over capacity.
879
- if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
880
- const oldestKey = avatarProxyCache.keys().next().value;
881
- if (oldestKey) avatarProxyCache.delete(oldestKey);
882
- }
883
- avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
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
- // Use the per-project bucket log that rollout.js emits — it already
1234
- // carries the actual tokens attributed to each (project_key, source,
1235
- // hour_start). Falling back to "session-file count × total tokens"
1236
- // (the old behavior) produced pure fiction: every short-and-hot
1237
- // project got the same weight as every long-and-cold one.
1238
- const projectQueuePath = path.join(
1239
- path.dirname(qp),
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
- const forceRefresh = url.searchParams.get("refresh");
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,