@ipv9/tokentracker-cli 0.39.43 → 0.39.44

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 (40) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-C_H8B1rI.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BE8I9B4-.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-DQ9gR343.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-Balfhw3-.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-hxi161Wl.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-B4zbh-jA.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-DLYT4NCO.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-DUyZrDnS.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-BySJoulk.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-DpiMLTPo.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C6je8upG.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-ByarX-vY.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-C-WAJGZ8.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-BhOg_qb4.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-Co2okzG-.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-q9UoIq7C.js} +12 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-0XiGBeUV.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-DmEIJPtg.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-QLM3Sd2C.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-Dtize6Tx.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +2 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +72 -0
  31. package/src/lib/local-api.js +306 -93
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/queue-compact.js +220 -0
  34. package/src/lib/rollout.js +76 -12
  35. package/src/lib/skills-manager.js +2 -2
  36. package/src/lib/usage-limits.js +20 -3
  37. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  38. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  39. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  40. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
package/src/lib/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require("node:fs/promises");
2
+ const { findRowViolations } = require("./queue-compact");
2
3
  const { constants } = require("node:fs");
3
4
  const path = require("node:path");
4
5
 
@@ -28,6 +29,9 @@ async function buildDoctorReport({
28
29
  if (paths.cliPath) {
29
30
  checks.push(await checkCliEntrypoint(paths.cliPath));
30
31
  }
32
+ if (paths.queuePath) {
33
+ checks.push(await checkQueueRows(paths.queuePath));
34
+ }
31
35
 
32
36
  // No cloud reachability check: TokenTracker is local-only, so there is no
33
37
  // remote endpoint whose availability could affect anything here.
@@ -358,8 +362,76 @@ function summarizeChecks(checks = []) {
358
362
  return summary;
359
363
  }
360
364
 
365
+ // CLAUDE.md states the column invariant in prose:
366
+ //
367
+ // total = input + output + cache_creation + cache_read + reasoning
368
+ //
369
+ // Nothing enforced it at runtime, so a miswritten or corrupt row was aggregated
370
+ // and rendered rather than flagged — and a parser bug of exactly that shape is
371
+ // the class CLAUDE.md records at 1.6-7x magnitude. Same conversion as the
372
+ // curated-expiry and version-lockstep checks: a rule that lived in a document
373
+ // starts running.
374
+ //
375
+ // A warn rather than a fail: the rows are already on disk and already being
376
+ // rendered, so failing the whole health check would be reporting a crisis the
377
+ // user cannot act on in the moment. What they can act on is knowing which rows,
378
+ // and how many.
379
+ const QUEUE_VIOLATIONS_SHOWN = 5;
380
+
381
+ function queueCheck(status, detail, meta) {
382
+ return { id: "queue.row_invariant", status, detail, critical: false, meta };
383
+ }
384
+
385
+ async function readQueueRowsForDoctor(queuePath) {
386
+ const raw = await fs.readFile(queuePath, "utf8");
387
+ const rows = [];
388
+ let malformed = 0;
389
+ for (const line of raw.split("\n")) {
390
+ if (!line.trim()) continue;
391
+ try {
392
+ rows.push(JSON.parse(line));
393
+ } catch {
394
+ malformed += 1;
395
+ }
396
+ }
397
+ return { rows, malformed };
398
+ }
399
+
400
+ async function checkQueueRows(queuePath) {
401
+ let rows;
402
+ let malformed;
403
+ try {
404
+ ({ rows, malformed } = await readQueueRowsForDoctor(queuePath));
405
+ } catch (err) {
406
+ if (err && err.code === "ENOENT") {
407
+ return queueCheck("ok", "no queue yet", { path: queuePath });
408
+ }
409
+ return queueCheck("warn", `queue unreadable: ${err?.message || err}`, { path: queuePath });
410
+ }
411
+
412
+ const violations = findRowViolations(rows);
413
+ if (violations.length === 0 && malformed === 0) {
414
+ return queueCheck("ok", `${rows.length} rows satisfy the column invariant`, {
415
+ path: queuePath,
416
+ rows: rows.length,
417
+ });
418
+ }
419
+
420
+ const parts = [];
421
+ if (violations.length > 0) parts.push(`${violations.length} row problem(s)`);
422
+ if (malformed > 0) parts.push(`${malformed} unparseable line(s)`);
423
+ return queueCheck("warn", `${parts.join(", ")} in ${rows.length + malformed} line(s)`, {
424
+ path: queuePath,
425
+ rows: rows.length,
426
+ malformed,
427
+ violations: violations.length,
428
+ examples: violations.slice(0, QUEUE_VIOLATIONS_SHOWN),
429
+ });
430
+ }
431
+
361
432
  module.exports = {
362
433
  buildDoctorReport,
434
+ checkQueueRows,
363
435
  buildBrowserOpenerCheck,
364
436
  buildNodeVersionCheck,
365
437
  isHeadlessEnvironment,
@@ -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
@@ -687,6 +769,182 @@ const HOP_BY_HOP_HEADERS = new Set([
687
769
  // Main handler factory
688
770
  // ---------------------------------------------------------------------------
689
771
 
772
+ // Project usage, filtered the way every other card is filtered.
773
+ //
774
+ // Extracted from the request handler so the aggregation can be tested without
775
+ // standing up a server — and because the handler block reached 129 lines once it
776
+ // stopped ignoring the query string.
777
+ // The client has always sent from/to/source/limit/timeZone; the project handler
778
+ // read none of them and aggregated the whole file unconditionally. Pick "24h"
779
+ // and every other card narrowed while Projects kept showing all-time totals,
780
+ // with nothing on screen saying so — someone comparing "this week's spend by
781
+ // repo" was reading lifetime numbers.
782
+ //
783
+ // Pure surfacing: hour_start is already on every row. Same shape as the other
784
+ // range-filtered handlers (getTimeZoneContext + rowDayKey), so a day boundary
785
+ // means the same thing on every card.
786
+ function buildProjectFilters(url) {
787
+ const from = url.searchParams.get("from") || "";
788
+ const to = url.searchParams.get("to") || "";
789
+ const timeZoneContext = getTimeZoneContext(url);
790
+ const requestedSource = (url.searchParams.get("source") || "").trim().toLowerCase();
791
+ const rawLimit = Number(url.searchParams.get("limit"));
792
+
793
+ return {
794
+ limit: Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : null,
795
+ // An absent bound means "no bound". The other handlers compare against ""
796
+ // directly, which is safe there because the client always sends both; here
797
+ // it does not, and `day <= ""` would silently return nothing.
798
+ inWindow(row) {
799
+ if (!from && !to) return true;
800
+ if (!row.hour_start) return false;
801
+ const day = rowDayKey(row, timeZoneContext);
802
+ return (!from || day >= from) && (!to || day <= to);
803
+ },
804
+ matchesSource(row) {
805
+ return !requestedSource || String(row.source || "").toLowerCase() === requestedSource;
806
+ },
807
+ };
808
+ }
809
+
810
+ // Which sources contributed usage in this window but carry no project
811
+ // attribution at all. Without naming them, their absence reads as "that tool
812
+ // cost nothing here" — the panel under-reports and looks complete while doing
813
+ // it. `projectBucketsQueued` exists in 7 of the parsers; Cursor, Copilot, Zed,
814
+ // Goose and Kiro have no per-repo story at all.
815
+ function findUnattributedSources({ queuePath, allProjectRows, inWindow, matchesSource }) {
816
+ const attributed = new Set(
817
+ allProjectRows.filter(inWindow).map((row) => String(row.source || "").toLowerCase()),
818
+ );
819
+ return [
820
+ ...new Set(
821
+ readQueueData(queuePath)
822
+ .filter((row) => inWindow(row) && matchesSource(row))
823
+ .map((row) => String(row.source || "").toLowerCase())
824
+ .filter((src) => src && !attributed.has(src)),
825
+ ),
826
+ ].sort();
827
+ }
828
+
829
+ // No project-attributed rows at all (project attribution never synced, or only
830
+ // non-project-capable CLIs used). Falls back to per-source totals so the panel
831
+ // is not simply empty. This path once ALSO ran for the non-empty case and
832
+ // produced fiction — "session-file count x total tokens" gave every short-and-
833
+ // hot project the same weight as every long-and-cold one. Empty case only.
834
+ function aggregateBySource(queuePath, inWindow, matchesSource) {
835
+ const bySrc = new Map();
836
+ for (const row of readQueueData(queuePath)) {
837
+ if (!inWindow(row) || !matchesSource(row)) continue;
838
+ const src = row.source || "unknown";
839
+ if (!bySrc.has(src)) {
840
+ bySrc.set(src, {
841
+ project_key: src,
842
+ // Synthetic source-only row: project_ref stays empty rather than
843
+ // fabricating `https://${src}.ai`, which resolves to unrelated domains
844
+ // (codex.ai, cursor.ai) and was sent to the dashboard as a clickable
845
+ // href before v0.11.1.
846
+ project_ref: "",
847
+ total_tokens: 0,
848
+ billable_total_tokens: 0,
849
+ });
850
+ }
851
+ bySrc.get(src).total_tokens += row.total_tokens || 0;
852
+ bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
853
+ }
854
+ return bySrc;
855
+ }
856
+
857
+ // Per-repo cost, which the data model could not express until project rows
858
+ // carried a model. computeRowCost is the same function the rest of the product
859
+ // prices with, so a repo total and a model total cannot disagree.
860
+ //
861
+ // A row with no model prices at 0 and is counted as UNATTRIBUTED rather than
862
+ // folded silently into the total — that is the #94 tier doing exactly what it
863
+ // was added for. "Recorded before we recorded models" renders honestly instead
864
+ // of as a confident $0.
865
+ function aggregateByProject(rows) {
866
+ const byProject = new Map();
867
+ for (const row of rows) {
868
+ const key = row.project_key || "unknown";
869
+ if (!byProject.has(key)) {
870
+ byProject.set(key, {
871
+ project_key: key,
872
+ project_ref: row.project_ref || key,
873
+ total_tokens: 0,
874
+ billable_total_tokens: 0,
875
+ total_cost_usd: 0,
876
+ unattributed_tokens: 0,
877
+ });
878
+ }
879
+ const agg = byProject.get(key);
880
+ const tokens = Number(row.total_tokens || 0);
881
+ agg.total_tokens += tokens;
882
+ agg.billable_total_tokens += tokens;
883
+ const model = typeof row.model === "string" ? row.model.trim() : "";
884
+ if (!model || model === "unknown") {
885
+ agg.unattributed_tokens += tokens;
886
+ } else {
887
+ agg.total_cost_usd += computeRowCost(row);
888
+ }
889
+ if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
890
+ }
891
+ return byProject;
892
+ }
893
+
894
+ // Rows -> ranked entries, in the shape the panel already renders (token counts
895
+ // as strings). Split out so buildProjectUsageSummary stays under the size rule.
896
+ function rankProjectEntries(byKey) {
897
+ return Array.from(byKey.values())
898
+ .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
899
+ .map((e) => ({
900
+ ...e,
901
+ total_tokens: String(e.total_tokens),
902
+ billable_total_tokens: String(e.billable_total_tokens),
903
+ // Strings for the same reason the token counts are: the panel formats
904
+ // them, and a float in JSON invites a rounding difference between the two
905
+ // sides of the wire.
906
+ total_cost_usd: (e.total_cost_usd ?? 0).toFixed(6),
907
+ unattributed_tokens: String(e.unattributed_tokens ?? 0),
908
+ }));
909
+ }
910
+
911
+ function buildProjectUsageSummary({ url, queuePath, projectQueuePath }) {
912
+ // Use the per-project bucket log that rollout.js emits — it already
913
+ // carries the actual tokens attributed to each (project_key, source,
914
+ // hour_start). Falling back to "session-file count × total tokens"
915
+ // (the old behavior) produced pure fiction: every short-and-hot
916
+ // project got the same weight as every long-and-cold one.
917
+ const allProjectRows = readProjectQueueData(projectQueuePath);
918
+
919
+ const { inWindow, matchesSource, limit } = buildProjectFilters(url);
920
+
921
+ const projectRows = allProjectRows.filter((row) => inWindow(row) && matchesSource(row));
922
+
923
+ const unattributedSources = findUnattributedSources({
924
+ queuePath,
925
+ allProjectRows,
926
+ inWindow,
927
+ matchesSource,
928
+ });
929
+
930
+ const byProject = aggregateByProject(projectRows);
931
+
932
+ let entries =
933
+ byProject.size === 0
934
+ ? rankProjectEntries(aggregateBySource(queuePath, inWindow, matchesSource))
935
+ : rankProjectEntries(byProject);
936
+
937
+ if (limit != null) entries = entries.slice(0, limit);
938
+
939
+ return {
940
+ generated_at: new Date().toISOString(),
941
+ entries,
942
+ // Named so the panel can say what it cannot account for, rather than
943
+ // letting a missing tool read as a tool that cost nothing.
944
+ unattributed_sources: unattributedSources,
945
+ };
946
+ }
947
+
690
948
  function createLocalApiHandler({ queuePath }) {
691
949
  const qp = queuePath || resolveQueuePath();
692
950
 
@@ -833,11 +1091,8 @@ function createLocalApiHandler({ queuePath }) {
833
1091
  "abs.twimg.com",
834
1092
  "api.dicebear.com",
835
1093
  ];
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);
1094
+ if (!isAllowedAvatarTarget(parsed, AVATAR_HOST_ALLOWLIST)) {
1095
+ json(res, { error: "Address not allowed" }, 403);
841
1096
  return true;
842
1097
  }
843
1098
 
@@ -855,15 +1110,22 @@ function createLocalApiHandler({ queuePath }) {
855
1110
  }
856
1111
 
857
1112
  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",
1113
+ const upstream = await fetchAvatarFollowingAllowlist(
1114
+ cacheKey,
1115
+ {
1116
+ method,
1117
+ headers: {
1118
+ accept: req.headers["accept"] || "image/*",
1119
+ "accept-language": req.headers["accept-language"] || "en",
1120
+ "user-agent": "TokenTracker/AvatarProxy",
1121
+ },
865
1122
  },
866
- });
1123
+ AVATAR_HOST_ALLOWLIST,
1124
+ );
1125
+ if (upstream === AVATAR_REDIRECT_BLOCKED) {
1126
+ json(res, { error: "Redirect target not allowed" }, 403);
1127
+ return true;
1128
+ }
867
1129
  if (!upstream.ok) {
868
1130
  json(res, { error: `Upstream ${upstream.status}` }, upstream.status);
869
1131
  return true;
@@ -873,15 +1135,21 @@ function createLocalApiHandler({ queuePath }) {
873
1135
  json(res, { error: "Not an image" }, 415);
874
1136
  return true;
875
1137
  }
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 });
1138
+ const body = await readCappedAvatarBody(upstream, AVATAR_PROXY_MAX_BYTES);
1139
+ if (body === null) {
1140
+ // Refused rather than served-but-not-cached, which is what the old
1141
+ // length check amounted to. An avatar this size is not an avatar, and
1142
+ // the dashboard falls back to its local icon on a failed image load.
1143
+ json(res, { error: "Avatar too large" }, 413);
1144
+ return true;
884
1145
  }
1146
+ // Within the cap by construction, so caching is unconditional.
1147
+ // Simple LRU: drop oldest if over capacity.
1148
+ if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
1149
+ const oldestKey = avatarProxyCache.keys().next().value;
1150
+ if (oldestKey) avatarProxyCache.delete(oldestKey);
1151
+ }
1152
+ avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
885
1153
  res.writeHead(200, {
886
1154
  "Content-Type": contentType,
887
1155
  "Cache-Control": "public, max-age=3600",
@@ -1230,78 +1498,14 @@ function createLocalApiHandler({ queuePath }) {
1230
1498
 
1231
1499
  // --- project-usage-summary ---
1232
1500
  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",
1501
+ json(
1502
+ res,
1503
+ buildProjectUsageSummary({
1504
+ url,
1505
+ queuePath: qp,
1506
+ projectQueuePath: path.join(path.dirname(qp), "project.queue.jsonl"),
1507
+ }),
1241
1508
  );
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
1509
  return true;
1306
1510
  }
1307
1511
 
@@ -1534,7 +1738,16 @@ function createLocalApiHandler({ queuePath }) {
1534
1738
  }
1535
1739
 
1536
1740
  module.exports = {
1741
+ // Exported for tests: the aggregation runs without a server.
1742
+ buildProjectUsageSummary,
1537
1743
  createLocalApiHandler,
1744
+ // Exported so the redirect allowlist can be tested directly: the SSRF this
1745
+ // closes is only observable across a redirect hop, which no handler-level
1746
+ // test reaches.
1747
+ fetchAvatarFollowingAllowlist,
1748
+ readCappedAvatarBody,
1749
+ isAllowedAvatarTarget,
1750
+ AVATAR_REDIRECT_BLOCKED,
1538
1751
  resolveQueuePath,
1539
1752
  // Exported for cross-consumer tests (pricing + native contract lock).
1540
1753
  MODEL_PRICING,