Skip to content

Latest commit

 

History

History

api

README.md

Package Validator API

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.

Modules

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

Endpoints

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.

Subscriptions

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.

Security headers

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.

Rate limiting

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.

Configuration

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.

Development

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.