NestJS service that reads a public repository's package.json through the GitHub GraphQL API, compares every dependency with the npm registry and returns a report of what is outdated. Subscribers get the report by email.
| Module | Responsibility |
|---|---|
config |
Loads and validates environment variables into a typed AppConfig |
github |
GraphQL client: repository lookup and HEAD:package.json contents |
dependencies |
Reads the latest dist-tag of every declared package from the npm registry (8 requests in flight, 5 s timeout) and groups what is outdated by section |
report |
ReportService builds the report (GitHub → registry) and keeps it for an hour; render-report renders it as HTML and text for email, escaping every value |
email |
Confirmation requests and reports through SendGrid; a no-op when it is not configured |
subscriptions |
SubscriptionService (request, confirm, deliver, remove), the hourly ReportSchedulerService and the SQLite store (node:sqlite, no native dependency; schema changes are ordered migrations tracked in PRAGMA user_version, one transaction each) |
routes |
HTTP layer only: the /v1 controllers (RepositoriesController, SubscriptionsController) and the DTOs |
health |
GET /health liveness probe |
Interactive documentation (Swagger UI) is served at /docs and the OpenAPI 3 document at /docs/openapi.json; both are generated from the DTO decorators at startup and can be switched off with DOCS_ENABLED=false. In Docker they are reachable through the UI container at http://localhost:8080/docs. The same document is committed at docs/openapi.json; regenerate it with npm run openapi after changing routes or DTOs, since the e2e suite fails once it goes stale.
| Method | Path | Body | Success | Errors |
|---|---|---|---|---|
GET |
/health |
200 { status: "ok" } |
||
GET |
/v1/repositories/:owner/:repo |
200 { valid } |
400 invalid name |
|
GET |
/v1/repositories/:owner/:repo/report |
200 report |
404 unknown repo, 422 no or invalid package.json, 502 GitHub unreachable |
|
POST |
/v1/subscriptions |
{ owner, repo, email, period: 6 | 12 | 24 | 168 } |
201 report, emailSent, subscription |
400, 404, 422, 502 GitHub unreachable |
POST |
/v1/subscriptions/:token/confirm |
200 owner, repo, email, subscription |
400 malformed token, 404 unknown or expired |
|
DELETE |
/v1/subscriptions/:token |
204 |
400 malformed token, 404 unknown token |
|
POST |
/v1/subscriptions/:token/unsubscribe |
204 |
400 malformed token, 404 unknown token |
A report looks like this:
{
"owner": "mmayadag",
"repo": "bicycle-in-izmir",
"outdated": {
"dependencies": [{ "name": "express", "current": "^4.17.1", "latest": "^5.1.0", "change": "major" }]
},
"generatedAt": "2026-09-15T13:00:00.000Z"
}A report is cached in memory for one hour per repository, so repeated requests and scheduled deliveries reuse it instead of querying GitHub and the npm registry again; generatedAt says when the lookup happened. Failures are never cached.
change compares the lowest versions the two ranges allow: major, minor or patch; a minor bump below 1.0.0 counts as major because caret ranges treat it as breaking. Tags, URLs and workspace references are unknown.
POST /v1/subscriptions returns the report immediately and stores one subscription per email and repository (case-insensitive); posting again only changes the period. A new address is pending: it receives a confirmation email, not the report, and the response carries subscription: { status: "pending", periodHours, nextReportAt: null }. Confirming through POST /v1/subscriptions/:token/confirm activates it and sends the first report; unconfirmed requests are deleted after 24 hours. An already confirmed address gets the report straight away and status: "active". A pending address is sent a new confirmation email at most once every 10 minutes per email and repository, and its confirmation link stops working once the 24-hour window has passed.
Every email links to the UI (PUBLIC_URL/?confirm=<token> and PUBLIC_URL/?unsubscribe=<token>), which asks for a click before calling the API, so mail scanners that follow links can neither confirm nor unsubscribe anyone. The token is never returned by the API.
An hourly job (@nestjs/schedule) emails every active subscription whose period has elapsed, so a report arrives within an hour of being due. A repository that fails (deleted, made private, no package.json) is logged and retried on the next run without blocking the others, and overlapping runs are skipped. Nothing is sent while SendGrid is not configured.
Every report email also carries List-Unsubscribe and List-Unsubscribe-Post headers pointing at POST /v1/subscriptions/:token/unsubscribe, so mail providers can unsubscribe an address with one click (RFC 8058) without visiting the UI.
helmet sets its default headers on every API response (CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS, no X-Powered-By). The UI container adds its own set for the static bundle in ui/Caddyfile.
A global guard limits each client IP per route with a fixed one-minute window: 10 requests for details, schedule and DELETE subscriptions (they call GitHub and npm or touch subscriptions), 60 for everything else. /health is never limited. Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a rejected request gets 429 with Retry-After.
The client address comes from X-Forwarded-For when the request arrives from a loopback or private network, which is how Caddy reaches the API in Docker. Counters live in memory, which suits the single API container; running several replicas would need a shared store. @nestjs/throttler is not used because its current release does not support NestJS 12.
| Variable | Required | Default | Description |
|---|---|---|---|
TOKEN |
yes | GitHub token used for the GraphQL API | |
GITHUB_ENDPOINT |
no | https://api.github.com/graphql |
GraphQL endpoint (GitHub Enterprise) |
NPM_REGISTRY |
no | https://registry.npmjs.org |
Registry or mirror queried for latest versions |
DOCS_ENABLED |
no | true |
false hides Swagger UI and the OpenAPI document (/docs answers 404) |
SENDGRID_API_KEY |
no | Enables email reports together with EMAIL_FROM |
|
EMAIL_FROM |
no | Verified sender address | |
EMAIL_SUBJECT |
no | Dependency report |
Appended to owner/repo in the subject |
PUBLIC_URL |
no | http://localhost:8080 |
Address of the UI, used for confirmation and unsubscribe links |
DATABASE_PATH |
no | data/package-validator.db |
SQLite file for subscriptions (:memory: for tests) |
PORT |
no | 3288 |
HTTP port |
CORS_ORIGIN |
no | Comma-separated origins; CORS stays off when unset | |
LOG_LEVEL |
no | log |
Most verbose level to print: fatal, error, warn, log, debug or verbose |
NODE_ENV |
no | production switches logs to one JSON object per line (set by the Docker image) |
Every response carries an X-Request-Id header (the caller's own value when it sends one, otherwise a UUID) and every request except /health produces one log line with method, path, status, duration and client address; subscription tokens are masked in the path. In production (JSON logs) every line written while handling a request also carries its requestId, so an error can be traced back to the request that caused it. Caddy passes the header through in both directions.
Invalid values stop the application at startup. .env is read from api/ and from the repository root.
Requires Node.js 24.15+. The API is a workspace of the repository root and imports its request and response types from @package-validator/contracts, so install and build from the root first:
npm ci # at the repository root
npm run build:contracts # packages/contracts/dist, needed before anything below
cd api
npm run start:dev # http://localhost:3288, watch mode
npm run lint # oxlint
npm run typecheck # tsc --noEmit
npm test # unit tests (Vitest)
npm run test:e2e # HTTP tests with GitHub, npm and SendGrid mocked
npm run build # dist/The project is native ESM ("type": "module", module: nodenext), so relative imports carry a .js extension.