04 · < 50ms read p95

E-Library REST API

RBAC-protected Express/TS API with Redis cache-aside and Prometheus SLIs.

TypeScript · Express · Postgres · Redis · Prometheus · EC2

// 01

System Architecture

// 02

ADRs

Layered security architectureinternal

Layered security architecture

Context

The API is publicly accessible and handles authentication, user-submitted text content, and file uploads. No single security control is sufficient — a defence-in-depth approach is needed where each layer stops a different class of attack.


Decision

1. Three-Tier Rate Limiting (Not a Single Global Limiter)

Three separate rate limiters are applied to different route categories:

LimiterLimitTargetKey Design Choice
API limiter100 req / 15 min / IPAll routesStandard throughput guard
Auth limiter5 req / 15 min / IPLogin/registerskipSuccessfulRequests: true — legitimate users are never penalized
Upload limiter10 uploads / hour / IPFile upload routesProtects S3 PUT costs, not just compute

Why skipSuccessfulRequests: true on auth routes: A user who successfully logs in 4 times and then fails once should not be locked out. The limit is designed to stop brute-force attacks (which produce only failures), not normal usage.

A single global limiter was rejected because the risk profile differs per route — a 100 req/15 min limit on auth would allow 100 password guesses; applying 5 req/15 min globally would break normal API usage.

2. Zod Schemas as the Validation + Sanitization Layer

All user input is validated through Zod schemas. Schemas use .transform() to sanitize immediately after validation:

.transform((val) => sanitizeInput(val))

This means validation and sanitization are the same step — it is architecturally impossible to validate a field without sanitizing it.

safeParse() is used throughout (not parse()). safeParse() returns a structured error instead of throwing, which allows controllers to return actionable 400 responses rather than catching unhandled exceptions.

3. HTML Sanitization with Zero Allowed Tags

sanitize-html is configured with zero allowed tags (allowedTags: [], disallowedTagsMode: 'discard'). All HTML is stripped from user-submitted text.

Rationale: eLibrary does not support rich text. Any HTML in a book title or author name is an injection attempt. Stripping it entirely (rather than allowing a safe subset) is simpler and more defensible.

Applied via Zod transform — cannot be bypassed by calling the wrong validation function.

4. argon2 for Password Hashing (Not bcrypt)

argon2 was chosen over bcrypt. It won the Password Hashing Competition in 2015 and is memory-hard by design, making it resistant to GPU and ASIC attacks that can run many bcrypt threads cheaply in parallel.

5. CORS with Explicit Origin Allowlist

origin: '*' was rejected. The API serves credentials (JWT tokens) and uses credentials: true implicitly. With origin: '*', credential-bearing cross-origin requests are rejected by browsers anyway — and the wildcard gives a false impression of openness.

The allowlist is explicit: localhost dev ports and the production domain only. Unknown origins are rejected at the CORS middleware before reaching any route handler.

Preflight responses are cached for 24 hours (maxAge: 86400) to reduce unnecessary OPTIONS round-trips.

6. JWT Authentication with Distinguishable Error Responses

The authentication middleware distinguishes between:

  • No token → 401 with "No authorization header" (not an attack, probably an unauthenticated request)
  • Invalid/expired token → 401 with "Invalid token" (possible replay or expired session)

This distinction matters for logging and metrics — the authAttemptsTotal Prometheus counter uses success / invalid labels so each type is independently observable.

7. File Upload Security via MIME Validation + Size Limits

  • Cover images: only image/jpeg, image/png, image/webp accepted
  • Book files: only application/pdf accepted
  • Max file size: 70MB; max files per request: 2
  • Filename generation: fieldname + Date.now() + random + extension — prevents path traversal attacks via crafted filenames

MIME type is checked server-side (not trusting the client's Content-Type header) using a custom fileFilter.

8. Stack Traces Suppressed in Production

The global error handler exposes errorStack only when config.env === 'dev'. In staging and production, clients see the error message only. Stack traces contain internal paths, dependency versions, and logic hints — none of which should be visible externally.

9. Request Pipeline Layer Order

Request
  → Rate Limiting       (stops volume attacks before any processing)
  → CORS Check          (rejects unauthorized origins)
  → JSON Parsing        (body parsing only for allowed content types)
  → JWT Auth            (identity verification on protected routes)
  → Zod Validation      (input validation + sanitization)
  → Business Logic
  → Global Error Handler (sanitizes error output)

The order is deliberate — cheaper, broader rejections happen before expensive operations.

CI/CD pipelineinternal

CI/CD pipeline

Context

The backend required a reliable deployment pipeline that could ship code to a staging EC2 server and automatically recover from bad deploys without manual intervention. A single failing deploy resulted in a manual SSH session to restart containers by hand.


Decision

1. Two Separate Workflow Files

The pipeline is split into ci.yml (runs on every PR and push to main) and cd-backend-staging.yml (runs only after CI passes on main pushes). This prevents CI from triggering a deploy on pull requests.

2. CI Step Ordering

Steps run in a deliberate sequence: install → lint → type-check → test → build.

  • Linting is fastest, so it runs first to fail early
  • tsc --noEmit runs before tests — no point running tests against broken types
  • Tests run before build — catch logic errors before paying for compilation

3. Path Filters on CI Triggers

Workflows use paths: filters scoped to backend/**. Without this, a frontend CSS change would trigger backend CI in this monorepo, wasting CI minutes.

4. CD Triggered via workflow_run

CD only starts when CI completes successfully (conclusion == 'success'), is a push event (not a PR), and targets main. This prevents manual workflow reruns or PR-triggered CI from accidentally deploying.

5. Concurrency Control with Cancel-in-Progress

concurrency:
  group: staging-deploy
  cancel-in-progress: true

Two rapid commits would cause two deploy jobs to race on the same EC2 instance, causing SSH commands to step on each other. The second deploy cancels the first — latest code always wins.

6. Build Once, Tag Twice (Immutable SHA Tag + Mutable staging Tag)

Every build produces two Docker image tags:

  • iarpitchauhan/elibrary-api:<git-sha> — immutable, tied to an exact commit
  • iarpitchauhan/elibrary-api:staging — mutable, always points to latest

The SHA tag is what enables rollback without rebuilding. Without it, rollback means re-running CI for an old commit — slow and fragile.

7. Health-Check-Gated Deploy with Automatic Rollback

The deploy script over SSH:

  1. Captures the currently running image tag before touching anything
  2. Deploys the new image via docker compose up -d
  3. Polls http://127.0.0.1:3001/health for up to 60 seconds (12 × 5s)
  4. If healthy: prune old images, exit 0
  5. If unhealthy: pull the previously captured SHA-tagged image and redeploy it
  6. Run the same health check loop on the rollback image
  7. Exit 1 even on a successful rollback — the deploy failed; reporting success would be misleading

8. Two Health Checks

  • Internal (127.0.0.1:3001/health): confirms the app started on the EC2 instance
  • External (https://<BACKEND_API_URL>/health): confirms reachability through the reverse proxy and DNS. These can diverge (e.g. a bad Nginx config passes the internal check but fails the external one)
← all projectsnext: Blogify Edge