Architecture
Repodar is one Cloudflare Worker. It serves the pages, receives GitHub webhooks and server
runtime reports (POST /ingest/runtime, authenticated by a per-team report token and usually sent
by the static script it serves at /report.sh, see server reports),
runs two cron triggers and consumes its own scan
queue. State lives in D1 (SQLite), short-lived caches in KV.
There is no other server.
Packages
| Path | What it is |
|---|---|
packages/detect |
Pure detection library: detect(files) turns a map of file paths to contents into runtimes, frameworks, dependencies and a deploy target. No I/O, no Cloudflare dependencies, tested against fixture repositories. |
packages/sources |
Thin typed HTTP clients: GitHub (App JWT, installation tokens, contents), endoflife.date, OSV.dev, Packagist, Forge, Cloudflare, Vercel, DigitalOcean and Lettr. Mockable, with retries and a KV-backed cache. |
apps/web |
The Worker: Hono routes, Better Auth (GitHub sign-in), Drizzle on D1, the scanner, the runtime sources, the digest and the UI. |
brand |
Tokens, logo, Echo and the voice rules. |
The UI is server-rendered HTML (Hono JSX). The few interactive parts (filter box, table sorting, settings forms) are small Vue 3 components loaded as ES modules from the Worker's static assets, with no bundler and no build step. Every page works without JavaScript.
The daily pipeline
06:00 UTC cron
└─ per team: collect-sources message
└─ each enabled source (Forge, Cloudflare, Vercel, DigitalOcean, GitHub Deployments, PAT)
→ observations → matched to repos → stored
└─ Forge: creates the day's version commands → forge-command-output message (+60 s)
└─ reads their output → exact versions on the sites → rescans changed repos
└─ then scan-team message
└─ one scan-repo message per active repo (queue: repodar-scan)
└─ fetch known files via GitHub contents API (parallel, 404s tolerated)
└─ detect() → merge matched observations → runtimes, frameworks, deploy
└─ endoflife.date (EOL), OSV.dev (npm, Go), Packagist (Composer)
└─ findings diff: new ones inserted, vanished ones resolved
└─ diff non-empty → team marked digest-pending
07:00 UTC cron
└─ each digest-pending team → render digest → Notifier (Lettr email) → digests row
A push webhook to a repository's default branch enqueues a scan-repo for that repository
immediately. Rescan and Scan all in the UI do the same, except that they first send a
collect-sources message for the workspace when it has an enabled source: the consumer collects,
then sends the scan-repo (Rescan) or scan-team (Scan all). A Rescan reads GitHub Deployments
only for the repository it rescans; Scan all reads them for every repository. Sources are
collected this way at most once every 5 minutes per workspace, a window shared with Collect
now and Check again in Settings; inside it Rescan and Scan all scan straight away with the
stored observations. The daily cron is separate. Manual scans count towards the same daily limits
as before (12 per repository, 500 per workspace).
Queue batches are small (5 repositories) because one batch is one Worker invocation and shares its
limits. At most two consumers run in parallel, which keeps GitHub's secondary rate limits (at
most 10 concurrent requests per installation) and the public APIs comfortable. A message that
fails three times goes to repodar-scan-dlq, whose consumer marks the scan as failed. A GitHub
rate limit is not a failure: the message is re-enqueued with GitHub's own delay.
Detection
detect() reads only the repository root (plus optional per-repository scan paths for a
monorepo) and the workflow files. Runtime precedence: platform observations, then pin files, then
CI setup steps, then manifest constraint minimums. See sources.md for every file
and rule.
Findings
Three kinds, each with a stable key so the same problem is never reported twice:
eol: a runtime, framework or base OS past end-of-life (or within 90 days, shown as "soon"). EOL data comes from the endoflife.date API, cached in KV for 24 hours. There are no hand-made EOL tables.vuln: an advisory against a dependency version. OSV.dev for npm, Go, PyPI, RubyGems and crates.io (batch query, then details per advisory), Packagist's security advisories for Composer. Queries are cached for 12 hours. Dev-only dependencies are flagged and left out of the digest by default.major_behind: a tracked framework at least one major version behind the newest.
A finding that no longer appears gets a resolved_at time; it is never deleted, so the digest can
say "resolved".
The digest
The 07:00 cron looks only at teams whose findings changed since their last digest: a finding
appeared or was resolved, a runtime version changed, a repository was added or removed. If nothing
changed, nothing is sent. The digest goes through a Notifier interface; Lettr email is the one
implementation today, so Slack or Discord would be one file each.
Data model
D1 tables (Drizzle schema in apps/web/src/db/schema/, migrations in apps/web/drizzle/):
| Table | Holds |
|---|---|
user, session, account, verification |
Better Auth: GitHub identity, sessions, the encrypted OAuth token |
teams, team_members |
Workspaces. Sign-up creates a personal one; plan, owner label map and digest state hang off it |
installations |
GitHub App installations linked to a team |
repos |
Watched repositories: GitHub id and name, default branch, deploy override, scan paths, archived, last push |
scans |
One row per scan with status and a redacted error |
repo_state |
The latest detected state: runtimes, frameworks, deploy target and its source, dependencies |
findings |
EOL, vulnerability and major-behind findings with first seen and resolved times |
digests |
Each digest sent: when, which channel, a summary |
notifier_settings |
Per-team digest channel settings |
team_sources |
Per-team runtime source: the encrypted token, last poll, last error |
team_source_observations |
What each source reported and how it is matched to a repository; server reports too (provider report) |
team_report_tokens |
Server report tokens: SHA-256 hash only, name, created, last used, revoked |
KV holds caches only: GitHub installation tokens until they expire, endoflife.date and advisory responses, webhook delivery ids (for deduplication), short-lived sign-in and install state, rate limit counters and the one-off keys of the report-token form. Losing KV costs a few extra API calls and nothing else.
Every repository belongs to a team. Teams exist in the schema from day one, but the interface never mentions them until a team has more than one member.
Billing
The hosted service can bill through Stripe (Checkout, a webhook and the customer portal), behind
the BILLING_ENABLED flag, which is off at launch. With billing off, or SELF_HOSTED=true, every
team is unlimited, no pricing is shown and the Stripe routes answer 404.