Skip to content

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.

Last updated · View as Markdown