← back to gallery

License Tilt

Live radar of OSS license changes — see your moat tilt from MIT to BUSL the moment it happens

dev-toolsosslicensesupply-chainnpmpypigithubspdxbuslssplfsl
Open product ↗

license-tilt

Live, public radar of OSS license changes across npm, PyPI and the GitHub repos that back them. When a popular package tilts from a permissive license (MIT / Apache-2.0) toward a source-available license (BUSL, SSPL, FSL, Elastic-2.0, Commons Clause) or proprietary, license-tilt catches it within an hour, classifies the move, computes the blast-radius, and publishes a public timeline.

Live: https://holyai.me/license-tilt/

Why this exists

Relicensing is the dev-tool boardroom topic of 2024–2026. MongoDB, Elastic, HashiCorp (Terraform / Vault / Consul / Nomad), Sentry, Redis, MariaDB MaxScale, Cockroach, Confluent — six out of nine large OSS-funded startups that crossed the same growth phase ended up restricting their license. Renovate / Snyk give you zero signal here; GitHub's license.spdx_id field is in the API but nobody publishes a live diff feed of it. We do.

The pain point in one sentence:

"I shipped on top of <library X>. It was MIT yesterday. Today it is BUSL with a 'no commercial host' grant. My CI is green, my SBOM is green, and I just lost my legal moat."

Quick start

npm install
cp .env.example .env
npm start
# open http://localhost:4861/license-tilt/

The first refresh cycle takes about 30 minutes — the dashboard fills in as packages get fetched. The boot sequence kicks the SPDX catalogue refresh immediately, then begins the rolling package refresh 5 seconds later.

Environment

| Variable | Default | Notes |
|-------------------|------------------------|-------|
| PORT | 4861 | HTTP port |
| BASE_PATH | /license-tilt | All routes mount under this prefix |
| NODE_ENV | production recommended | Boots the immediate refresh cycle |
| GITHUB_TOKEN | unset | Optional; raises GitHub REST rate from 60 to 5000/h |
| LICENSE_TILT_DB | ./license-tilt.db | SQLite path |

No auth env vars exist. Every endpoint, including POST /api/refresh, is public on purpose.

HTTP API (all public, no auth)

| Method | Path | Description |
|--------|-----------------------------------------------|-------------|
| GET | /license-tilt/health | liveness, returns last tilt timestamp |
| GET | /license-tilt/api/stats | dashboard hero stats |
| GET | /license-tilt/api/tilts?period=30d&direction=&ecosystem=&limit=&offset= | tilt feed |
| GET | /license-tilt/api/packages?ecosystem=&category=&sort=&q=&limit=&offset= | tracked packages |
| GET | /license-tilt/api/packages/:ecosystem/:name | per-package detail + timeline |
| GET | /license-tilt/api/risk?limit=50 | risk leaderboard |
| GET | /license-tilt/api/categories | per-category counts + examples |
| GET | /license-tilt/api/fetch-log?limit=100 | recent fetcher events (transparency) |
| GET | /license-tilt/api/sources | last 24h fetch ok/bad counts per source |
| POST | /license-tilt/api/refresh {ecosystem,name}| trigger an immediate refresh of one package (rate-limited 30/IP/min) |

Where every number on this page comes from

All license values are fetched live at runtime — no mocks, no seeds, no preset arrays.

| # | Source | URL | Auth | Refresh | Used for |
|---|------------------------------|--------------------------------------------------------------------------------------|--------------------------|----------------------------------|----------|
| 1 | npm registry | https://registry.npmjs.org/{package} | none | every 6h, top 200 by downloads | license field for npm packages |
| 2 | npm downloads | https://api.npmjs.org/downloads/point/last-week/{package} | none | daily | weekly download count (blast-radius weight) |
| 3 | PyPI JSON | https://pypi.org/pypi/{package}/json | none | every 6h, top 200 | info.license + classifiers |
| 4 | PyPI downloads | https://pypistats.org/api/packages/{package}/recent | none | daily | last-week downloads (blast-radius weight) |
| 5 | GitHub repo metadata | https://api.github.com/repos/{owner}/{repo} | optional GITHUB_TOKEN | every 4h, tracked repos | license.spdx_id, stars, pushed_at |
| 6 | GitHub LICENSE-file history | https://api.github.com/repos/{owner}/{repo}/commits?path=LICENSE (also LICENSE.md, LICENSE.txt, COPYING) | optional GITHUB_TOKEN | daily | exact tilt-event commit / date / message |
| 7 | SPDX license catalogue | https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json | none | weekly | canonical SPDX list + OSI / FSF flags |
| 8 | OpenSSF Scorecard | https://api.securityscorecards.dev/projects/github.com/{owner}/{repo} | none | every 7d | criticality (input to risk score) |

If a source is unreachable, the fetcher logs the failure into fetch_log and leaves the previous snapshot untouched. Nothing on the dashboard is invented when a fetch fails — the "Source freshness" panel on the overview shows the truth.

Static lookup tables (heuristics only)

These two files never affect license values. They feed only the risk_score heuristic:

Update them quarterly; nothing else in the product references them.

Seeds — names only

Every package's actual license string is fetched live; the seed files only say what to track.

Schema

packages (1 row per tracked artifact) → license_snapshots (1 row per refresh that saw a new value) → tilts (1 row per detected category change). Plus license_catalog (SPDX), fetch_log (audit trail), schema_meta. See db.js for the DDL.

Cron schedule

| Cron expression | Job |
|------------------|-----|
| /30 | refresh next 25 packages (full sweep in ~12h for 600 tracked) |
| 0
/4 | refresh GitHub repo metadata (stars, pushed_at) for top 100 |
| 0 1
| refresh weekly downloads for every npm + PyPI package |
| 0 2 | for tilts missing commit context, look up LICENSE-file commits |
| 30 3
| recompute risk scores |
| 0 6 0 | refresh SPDX licence catalogue (weekly) |

License

MIT