Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,22 @@ No account, no upload of your usage, and no server to sign in to.
| **Auditable in one command** | You don't have to take our word for it — the store is an append-only text file you can open yourself: `cat ~/.tokentracker/tracker/queue.jsonl`. It's numbers and timestamps. |
| **No telemetry** | No analytics, no crash reporting, no phone-home, no account. |

**Outbound calls, on your behalf only.** TokenTracker is local-first, not network-free. It talks to the internet in exactly these cases, and none of them carry your usage data:

| When | Where | Why |
|---|---|---|
| Pricing refresh (daily) | `raw.githubusercontent.com` | Downloads the public [LiteLLM](https://github.com/BerriAI/litellm) price list. Anonymous — no credentials, nothing sent. Works offline from a bundled snapshot. |
| Quota chips + Limits page | `api.anthropic.com`, `chatgpt.com`, `cursor.com`, `cloudcode-pa.googleapis.com`, `api.kimi.com`, `api.z.ai`, `api.github.com` | Asks *your* provider about *your* plan limits, using credentials already on your machine. Only for providers you actually use. |
| Token refresh | `auth.openai.com`, `oauth2.googleapis.com`, `auth.kimi.com` | Renews those same provider credentials when they expire. |
| Profile avatars | Allowlisted avatar CDNs | Fetched server-side so your browser doesn't contact them directly. |
| IP check page | `ip.net.coffee` | Only if you open that page. |
| `npx` startup | npm registry | How `npx` works — it downloads the package. A global install avoids it. |
**Outbound calls.** TokenTracker is local-first, not network-free. It reaches these hosts and no others. None of them carry your usage data — but some do reveal that *you* are asking, so they are listed with who makes the call.

This table is checked in CI against [`outbound-hosts.json`](outbound-hosts.json): a host the code can reach but the file does not declare fails the build, and so does a declared host missing from this table. It is not maintained by memory.

| When | Host | From | Why |
|---|---|---|---|
| Pricing refresh (daily) | `raw.githubusercontent.com` | server | The public [LiteLLM](https://github.com/BerriAI/litellm) price list. Anonymous — no credentials, nothing sent. Works offline from a bundled snapshot. |
| Quota chips + Limits page | `api.anthropic.com`, `chatgpt.com`, `api.openai.com`, `cursor.com`, `www.cursor.com`, `cloudcode-pa.googleapis.com`, `api.kimi.com`, `api.z.ai`, `api.github.com` | server | Asks *your* provider about *your* plan limits, using credentials already on your machine. Only for providers you actually use. |
| Token refresh | `auth.openai.com`, `oauth2.googleapis.com`, `auth.kimi.com` | server | Renews those same provider credentials when they expire. |
| Skills tab | `skills.sh`, `api.github.com`, `github.com` | server | Searches the public skills directory and reads public repository metadata. Sends your search terms, nothing else. Only when you open that tab. |
| Currency conversion | `open.er-api.com` | **browser** | Public USD exchange rates. Anonymous. Only when you pick a non-USD currency. |
| Star count in the header | `api.github.com` | **browser** | The star count for this project's own repository — a fixed public URL that says nothing about you. |
| IP check page | `ip.net.coffee`, `1.1.1.1`, `claude.ai`, `www.anthropic.com` | server + **browser** | Only if you open that page, whose entire purpose is showing you your own IP and whether Anthropic is reachable. |
| `npx` startup | `registry.npmjs.org` | server | How `npx` works — it downloads the package. A global install avoids it. |

**What is deliberately absent:** the Projects panel does not fetch repository avatars or star counts. Doing so would have put the name of a repository you have checked out — a private one included — into a URL sent to GitHub from your browser. It did, until [#100](https://github.com/pitimon/TokenTracker/issues/100). Project rows now render a local icon.

---

Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/LocalOnlyNotice.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import { Download, ArrowUpRight } from "lucide-react";
import { copy } from "../lib/copy";

const RELEASES_URL = "https://github.com/mm7894215/TokenTracker/releases/latest";
const RELEASES_URL = "https://github.com/pitimon/TokenTracker/releases/latest";

/**
* Empty state for local-first pages (Limits, Skills) when viewed on the
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/WidgetsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ function HeaderCta() {
if (platform === "mac-web") {
return (
<a
href="https://github.com/mm7894215/TokenTracker/releases/latest"
href="https://github.com/pitimon/TokenTracker/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-10 items-center gap-2 rounded-lg bg-oai-black px-4 text-sm font-medium text-white no-underline transition-colors hover:bg-oai-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-oai-brand-500 focus-visible:ring-offset-2 dark:bg-white dark:text-oai-black dark:hover:bg-oai-gray-200"
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/ui/components/HeaderGithubStar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { shouldFetchGithubStars } from "../dashboard/util/should-fetch-github-st
/**
* Dashboard / marketing header: single row — icon + Star + count (matches Shell header).
*/
export function HeaderGithubStar({ repo = "mm7894215/TokenTracker" }) {
export function HeaderGithubStar({ repo = "pitimon/TokenTracker" }) {
const [stars, setStars] = useState(null);

useEffect(() => {
Expand Down
17 changes: 4 additions & 13 deletions dashboard/src/ui/dashboard/components/DataDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,15 @@ function splitProjectKey(value) {
return { owner: owner || "", repo: repo || "" };
}

// Local icon only. `owner` comes from a repo the user has checked out, so an
// <img src="https://github.com/${owner}.png"> would disclose that name — a
// private one included — to a third party straight from the browser. An image
// load leaks exactly what a fetch does, which is how issue 100 went unnoticed.
function ProjectAvatar({ projectKey, projectRef }) {
const [imageFailed, setImageFailed] = useState(false);
const normalizedRef =
typeof projectRef === "string" ? projectRef.replace("https://github.com/", "") : "";
const { owner, repo } = splitProjectKey(projectKey || normalizedRef);
const repoId = owner && repo ? `${owner}/${repo}` : projectKey;
const avatarUrl = owner && !imageFailed ? `https://github.com/${owner}.png?size=80` : "";

if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
className="w-8 h-8 rounded-md oai-bg-elevated object-cover"
onError={() => setImageFailed(true)}
/>
);
}

return (
<div className="w-8 h-8 rounded-md oai-bg-elevated flex items-center justify-center text-oai-gray-500 dark:text-oai-gray-300">
Expand Down
99 changes: 8 additions & 91 deletions dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo } from "react";

import { Select } from "../../components";
import { copy } from "../../../lib/copy";
import { formatCompactNumber, toDisplayNumber, toFiniteNumber } from "../../../lib/format";
import { shouldFetchGithubStars } from "../util/should-fetch-github-stars.js";
import { ProviderIcon } from "./ProviderIcon";

const LIMIT_OPTIONS = [3, 6, 10];
const DEFAULT_LIMIT = 10;
const REPO_META_CACHE = new Map();

function splitRepoKey(value) {
if (typeof value !== "string") return { owner: "", repo: "" };
const [owner, repo] = value.split("/");
return { owner: owner || "", repo: repo || "" };
}

function normalizeStars(value) {
if (!Number.isFinite(value)) return null;
return Math.max(0, Math.round(value));
}

function resolveTokens(entry) {
if (!entry) return null;
const total = entry.total_tokens ?? null;
Expand All @@ -33,65 +25,6 @@ function resolveTokens(entry) {
return billable ?? total ?? null;
}

function resolveRepoMeta(repoId) {
if (!repoId) return null;
return REPO_META_CACHE.get(repoId) || null;
}

function cacheRepoMeta(repoId, meta) {
if (!repoId || !meta) return;
REPO_META_CACHE.set(repoId, meta);
}

function useGithubRepoMeta(repoId) {
const [state, setState] = useState(() => resolveRepoMeta(repoId) || null);

useEffect(() => {
if (!repoId) return;
const cached = resolveRepoMeta(repoId);
if (cached) {
setState(cached);
return;
}

if (typeof window === "undefined") return;
const prefersReducedMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const screenshotCapture =
typeof document !== "undefined" &&
(document.documentElement?.classList.contains("screenshot-capture") ||
document.body?.classList.contains("screenshot-capture"));
if (!shouldFetchGithubStars({ prefersReducedMotion, screenshotCapture })) {
return;
}

let active = true;
fetch(`https://api.github.com/repos/${repoId}`)
.then((res) => res.json())
.then((data) => {
if (!active) return;
const meta = {
stars: normalizeStars(data?.stargazers_count),
avatarUrl: typeof data?.owner?.avatar_url === "string" ? data.owner.avatar_url : null,
};
cacheRepoMeta(repoId, meta);
setState(meta);
})
.catch(() => {
if (!active) return;
const meta = { stars: null, avatarUrl: null };
cacheRepoMeta(repoId, meta);
setState(meta);
});

return () => {
active = false;
};
}, [repoId]);

return state;
}

export function ProjectUsagePanel({
entries = [],
Expand All @@ -103,7 +36,6 @@ export function ProjectUsagePanel({
}) {
const placeholder = copy("shared.placeholder.short");
const tokensLabel = copy("dashboard.projects.tokens_label");
const starsLabel = copy("dashboard.projects.stars_label");
const emptyLabel = copy("dashboard.projects.empty");
const limitLabel = copy("dashboard.projects.limit_label");
const limitAria = copy("dashboard.projects.limit_aria");
Expand Down Expand Up @@ -162,7 +94,6 @@ export function ProjectUsagePanel({
entry={entry}
placeholder={placeholder}
tokensLabel={tokensLabel}
starsLabel={starsLabel}
tokenFormatOptions={tokenFormatOptions}
/>
))}
Expand All @@ -176,25 +107,13 @@ function ProjectUsageCard({
entry,
placeholder,
tokensLabel,
starsLabel,
tokenFormatOptions,
}) {
const repoKey = typeof entry?.project_key === "string" ? entry.project_key : "";
const projectRef = typeof entry?.project_ref === "string" ? entry.project_ref : "";
const { owner, repo } = splitRepoKey(
const { repo } = splitRepoKey(
repoKey || projectRef.replace("https://github.com/", "")
);
const repoId = owner && repo ? `${owner}/${repo}` : repoKey;
const meta = useGithubRepoMeta(repoId);
const avatarUrl =
meta?.avatarUrl || (owner ? `https://github.com/${owner}.png?size=80` : "");
const starsRaw = meta?.stars;
const starsFull =
starsRaw == null ? placeholder : toDisplayNumber(starsRaw);
const starsCompact =
starsRaw == null
? placeholder
: formatCompactNumber(starsRaw, tokenFormatOptions);
const tokensRaw = resolveTokens(entry);
const tokensFull =
tokensRaw == null ? placeholder : toDisplayNumber(tokensRaw);
Expand All @@ -205,19 +124,17 @@ function ProjectUsageCard({

return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-oai-gray-200 dark:border-oai-gray-700">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="w-10 h-10 rounded bg-oai-gray-100 dark:bg-oai-gray-800 object-cover" />
) : (
<div className="w-10 h-10 rounded bg-oai-gray-100 dark:bg-oai-gray-800 flex items-center justify-center">
<ProviderIcon provider={repoKey} size={24} />
</div>
)}
{/* Local icon only. This row is keyed by a repo you have checked out, so
fetching a remote avatar would send that name — including a private
one — to a third party from the user's browser. See issue 100. */}
<div className="w-10 h-10 rounded bg-oai-gray-100 dark:bg-oai-gray-800 flex items-center justify-center">
<ProviderIcon provider={repoKey} size={24} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-oai-black dark:text-oai-white truncate">
{repo || repoKey || placeholder}
</div>
<div className="flex items-center gap-3 text-xs text-oai-gray-400 dark:text-oai-gray-400 mt-0.5">
<span>★ {starsCompact}</span>
<span>{tokensCompact}</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ function renderDetails(props = {}) {
}

describe("DataDetails", () => {
it("uses an owner avatar for project usage rows instead of a letter initial", () => {
it("never loads a remote avatar for a project row (issue 100)", () => {
const { container } = renderDetails();

fireEvent.click(screen.getByRole("tab", { name: "Project Usage" }));

const avatar = container.querySelector('img[src="https://github.com/pitimon.png?size=80"]');
expect(avatar).toBeInTheDocument();
// The owner name comes from a repo the user has checked out. An <img src>
// pointing at the owner's github.com avatar discloses it to a third party from the
// browser just as a fetch would — that is how issue 100 shipped unnoticed.
expect(container.querySelector('img[src*="github.com"]')).toBeNull();
expect(container.querySelector("img")).toBeNull();
expect(screen.queryByText("P")).not.toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ describe("ProjectUsagePanel", () => {
const { container } = render(<ProjectUsagePanel entries={[entry]} />);

expect(screen.getByText("hello")).toBeInTheDocument();
expect(screen.getByText(/★/)).toBeInTheDocument();
// No star count and no remote avatar: both were fetched from
// api.github.com with a checked-out repo name in the URL path (issue 100).
expect(screen.queryByText(/★/)).toBeNull();
expect(container.querySelector("a[href]")).toBeNull();
});

Expand Down
Loading