DEV Community

Dhardingsea Developer
Dhardingsea Developer

Posted on

I built a local-first Dota 2 companion as a Chrome extension — and learned what "minimum permissions" actually means

Building a no-account, no-API-key Dota 2 stats extension, and three Chrome/Steam lessons that cost me a Web Store rejection.
https://dhseadev.online/projects/dota-companion/

Most Dota 2 stat sites answer "how did that game go?" I wanted one that answers a harder question: am I actually getting better?

So I built Dota Companion — a Chrome extension that lives in your toolbar. No account, no API key, no signup. Paste a Steam profile URL and it works.

The build taught me three things worth passing on, one of which got the extension flagged by the Chrome Web Store.

The feature I actually cared about: improvement streak

Win streaks measure luck as much as skill. In a five-player team game, you can play well and lose.

So the headline metric isn't wins — it's an improvement streak: consecutive games where your GPM beat your own 20-game baseline. It's a metric you control, and it moves when you improve rather than when your team cooperates.

Same idea drives goals. Pick GPM, KDA, last hits, or win rate; the baseline snapshots your current 20-game average when you set the goal. You compete with past-you.

The rest is the between-games layer: today's W/L, a GPM trend sparkline, recent matches with hero, K/D/A, duration and how long ago, most-played heroes, and private notes and tags on players you run into — stored on your device, never uploaded.

Lesson 1: chrome.tabs.create() does not need the tabs permission

This one cost me a rejection email.

I had one tabs call — opening a bundled inventory page:

chrome.tabs.create({ url: chrome.runtime.getURL('inventory.html') });
Enter fullscreen mode Exit fullscreen mode

So "tabs" went in the manifest. Reasonable, right?

Wrong. Google flagged it under their minimum-permissions policy:

The following permission(s) need not be requested for the methods/properties implemented by the item: tabs

chrome.tabs.create() has never required the tabs permission. No tab-creation API does. That permission gates exactly one thing: reading sensitive tab metadataurl, pendingUrl, title, favIconUrl — off Tab objects.

I passed a URL in and ignored the returned object. I was declaring a capability I never used.

I verified the fix rather than trusting the docs — loaded the extension with tabs removed and called the API in a real browser:

runtime granted permissions: {"permissions":["storage"],"origins":["https://api.opendota.com/*"]}
chrome.tabs.create -> {"ok":true,"tabId":210614909}
pages before/after: 2 / 3
errors: NONE

Tab opened. Zero errors. The permission was pure liability.

Takeaway: grep your actual call sites before declaring anything. "This API sounds tab-related, so I need tabs" is exactly the reasoning the policy exists to catch. Ask instead: do I read the returned object's sensitive fields? If no, you don't need it.

Lesson 2: rate limits you share with the user are a design constraint, not an error case

The extension can estimate your Dota 2 inventory value from Steam Community Market prices. Steam rate-limits those endpoints hard — and critically, the limit applies to the whole IP, not to your extension.

Blow through it and you don't just break your feature. You break the user's normal Steam browsing. That reframes the problem: you're not optimizing your own throughput, you're spending a budget that isn't yours.

So the design is deliberately timid:

  • Nothing fetches automatically. Every request traces back to a button the user pressed.
  • A visible request budget, capped well short of the real limit, shown in the UI.
  • Hard backoff. If Steam pushes back, it stops for hours rather than retrying.
  • No Steam login cookies are sent. Public data only.

Slower than technically possible, on purpose. A tool that degrades the rest of your browsing isn't a good tool.

Lesson 3: optional permissions are worth the extra state

The inventory feature needs steamcommunity.com access. Most users never open it.

Requesting that host at install time means every user sees a scary permission prompt for a feature they may never touch. So it's an optional host permission, requested at the moment of use:

chrome.permissions.contains({ origins: [ORIGIN] }, cb);  // gate the UI
chrome.permissions.request({ origins: [ORIGIN] }, cb);   // only on click
Enter fullscreen mode Exit fullscreen mode

Cost: a real permission-state machine — gated UI, a grant screen, a declined path. Benefit: the install prompt asks for nothing surprising, and the request arrives with obvious context.

Worth it. Install-time friction is where extensions die.

Testing logic that lives in a popup

Extension code resists testing — everything wants chrome.* or the DOM. The fix was a hard boundary: all pure logic (streaks, baselines, formatting, aggregation) sits in modules that touch nothing — no chrome.*, no DOM, no fetch, not even the clock. Current time is passed in as an argument.

Those modules dual-export: a global for the extension, module.exports for Node. The whole stat layer runs headless with zero browser mocks.

That caught a genuinely nasty one. OpenDota returns start_time in epoch seconds. new Date(start_time) expects milliseconds — so every match silently renders as 1970. There's now a regression test asserting exactly that.

Try it

Data comes from the OpenDota API — you'll need "Expose Public Match Data" enabled in Dota 2 settings for matches to show up. English and Simplified Chinese.

Not affiliated with Valve.

Top comments (0)