-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.js
More file actions
319 lines (293 loc) · 13.5 KB
/
server.js
File metadata and controls
319 lines (293 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
require('dotenv').config();
const crypto = require('node:crypto');
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const pinoHttp = require('pino-http');
const db = require('./app/config/db.config.js');
const log = require('./app/config/logger.js');
const router = require('./app/routers/router.js');
const { errorHandler, notFound } = require('./app/middleware/error-handler.js');
const { metricsMiddleware } = require('./app/middleware/metrics.js');
const { redactUrl } = require('./app/middleware/redact-url.js');
const app = express();
// Defense-in-depth: helmet already strips X-Powered-By, but this
// disables it at the express level too in case a future middleware
// re-adds it (e.g. a buggy plugin doing `res.setHeader`).
app.disable('x-powered-by');
// ETag generation isn't useful for an authKey-scoped JSON API where
// every response is per-user — clients can't safely cache, and the
// hash computation costs CPU for nothing. Disable it explicitly.
app.set('etag', false);
// Structured request logging (pino-http). One JSON line per request
// with method, path, status, response time, and the per-request
// child logger available as req.log inside controllers.
// Healthz probes are quieted to `silent` to avoid drowning the log
// stream — orchestrator probes hit it on tight intervals and noisy
// success-rows for them are pure noise.
app.use(pinoHttp({
logger: log,
customLogLevel: (req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
if (req.url === '/healthz') return 'silent';
return 'info';
},
autoLogging: { ignore: () => false },
// Trust an incoming X-Request-Id header if present (so a reverse
// proxy / mesh can propagate trace context), otherwise generate
// a fresh one. The id lands on req.id, is echoed back on the
// X-Request-Id response header, and is included in every log
// line via pino-http's reqId binding.
//
// Validate the incoming value's character set, not just its
// length: Node's `res.setHeader` throws ERR_INVALID_CHAR on
// anything outside the printable-ASCII range (e.g. CR, LF, NUL),
// and a thrown setHeader inside pino-http's request hook
// crashes the request handler and surfaces as a 500. Limiting
// to printable ASCII [0x21..0x7e] (no spaces, no control chars)
// matches the W3C trace-id alphabet and the de-facto format used
// by every proxy / mesh / APM agent we'd be propagating from.
genReqId: (req, res) => {
const incoming = req.headers['x-request-id'];
const valid = typeof incoming === 'string'
&& incoming.length > 0
&& incoming.length <= 128
&& /^[\x21-\x7e]+$/.test(incoming);
const reqId = valid ? incoming : crypto.randomUUID();
res.setHeader('X-Request-Id', reqId);
return reqId;
},
serializers: {
req: (req) => ({
method: req.method,
// url is redacted at the serializer boundary — if an SDK
// mistakenly puts the authKey in the query string instead
// of the header, the raw value still doesn't land in the
// structured log. Header values are separately covered by
// logger.js's redact paths.
url: redactUrl(req.url),
remoteAddress: req.remoteAddress,
}),
},
}));
// Trust proxy headers when running behind nginx/caddy/cloudflare so
// rate-limit keys on the real client IP instead of the proxy IP.
// Operators opt in via TRUST_PROXY (true|false|<hop count>). Default
// false to avoid the security pitfall of trusting X-Forwarded-For
// from a non-proxied client.
//
// The hop-count branch uses a strict regex match (^\d+$) rather than
// `parseInt + !isNaN`, because parseInt is lenient — it would happily
// turn `TRUST_PROXY=1abc` (an operator typo) into `1` and silently
// trust one hop. With the regex an invalid value falls through to
// the implicit Express default (no trust) instead of partially
// honoring a malformed setting.
const trustProxy = process.env.TRUST_PROXY;
if (trustProxy === 'true') {
app.set('trust proxy', true);
} else if (typeof trustProxy === 'string' && /^\d+$/.test(trustProxy)) {
app.set('trust proxy', parseInt(trustProxy, 10));
}
// Security headers via helmet. Defaults are sensible for an API:
// X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
// Strict-Transport-Security (when behind TLS), etc. We disable
// contentSecurityPolicy by default because this is a JSON API
// (no HTML to protect) and a misconfigured CSP can break
// legitimate clients hitting the docs endpoint or future
// browser-based dashboards. Operators who add an HTML surface
// can re-enable via HELMET_CSP=1.
//
// crossOriginEmbedderPolicy is also disabled: Swagger UI at /docs
// loads its JS/CSS bundle from the package's own host but pulls
// theme assets cross-origin, and helmet's default
// `require-corp` value blocks any sub-resource that doesn't
// explicitly opt into CORP/CORS — which would break the docs
// page on first load. Since this API has no other browser-facing
// HTML, leaving COEP off is the lower-risk choice. Operators
// hosting embedded dashboards alongside the API should configure
// helmet directly rather than re-enabling COEP at this layer.
app.use(helmet({
contentSecurityPolicy: process.env.HELMET_CSP === '1' ? undefined : false,
crossOriginEmbedderPolicy: false,
}));
// CORS — env-configurable. Accept a single origin or a comma-separated
// list. Default to no cross-origin access; operators must opt in by
// setting CORS_ORIGIN explicitly.
const corsOrigin = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',').map((s) => s.trim()).filter(Boolean)
: false;
app.use(cors({
origin: corsOrigin,
optionsSuccessStatus: 200,
// Headers the browser JS layer needs to read off cross-origin
// responses. CORS hides any header not on the safelist unless we
// expose it explicitly here:
// - Link RFC 5988 pagination (next/prev/first/last)
// - X-Request-Id correlate a 5xx with a server log line
// - Idempotency-Replay flagged when the response is a replay,
// not a fresh write (P3-G)
// - RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
// standard RFC headers from express-rate-limit
exposedHeaders: [
'Link',
'X-Request-Id',
'Idempotency-Replay',
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
// Retry-After is set on the 429 response by express-rate-limit
// (standardHeaders: true). It carries the seconds the client
// should wait before retrying — without exposing it across
// CORS, browser JS gets `undefined` and clients have to fall
// back to a fixed-delay retry instead of honoring the value
// the server intended. Retry-After is NOT on the CORS
// safelisted-response-headers list, so explicit exposure is
// required.
'Retry-After',
],
}));
// Body size limit. The default in express.json() is 100kb; we make
// it explicit + env-tunable. Capping the body size is a basic
// defense against memory-exhaustion DoS — even an unauthenticated
// caller can otherwise force the server to buffer arbitrarily
// large JSON strings before the parser can reject them.
//
// 100kb is comfortably above any expected payload here (the largest
// real body is a TimeEntry create with a teDescription, capped at
// 10000 chars in the zod schema). Operators with unusual needs can
// override via JSON_BODY_LIMIT=512kb (etc.).
app.use(express.json({
limit: process.env.JSON_BODY_LIMIT || '100kb',
}));
// Rate limit the v1 surface to defend against authKey brute-force.
// Defaults: 100 requests / 15-minute window. Operators can tune via
// RATE_LIMIT_WINDOW_MS and RATE_LIMIT_MAX. Set RATE_LIMIT_MAX=0 to
// disable entirely (e.g. for load testing). /healthz is intentionally
// NOT rate-limited so orchestrator probes never trip it.
//
// Key derivation:
// - Authenticated requests (authKey header present): key by the
// hash prefix of that authKey. Mobile-carrier-NAT users sharing
// an IP no longer poison each other's budget; brute-force
// attempts get cut off per-key regardless of how many IPs the
// attacker rotates through.
// - Anonymous requests (no header): key by IP, the
// express-rate-limit default. This is the brute-force path —
// someone trying keys to find a valid one — and per-IP is the
// right granularity there.
// Same parseInt-leniency caveat as PORT (#124) and DB_PORT (#293):
// `parseInt('100abc')` returns 100, so an operator typo silently
// turns into a different-than-expected setting. Validate the raw
// string with a strict regex first so only clean integer-looking
// values reach parseInt; anything else falls through to the env
// var being treated as unset (which lets the inner finite-+>0
// fallback below pick the default).
function strictParseIntEnv(raw) {
if (typeof raw !== 'string') return NaN;
return /^-?\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
}
const rateLimitMax = strictParseIntEnv(process.env.RATE_LIMIT_MAX);
const rateLimitWindowMs = strictParseIntEnv(process.env.RATE_LIMIT_WINDOW_MS);
if (rateLimitMax !== 0) {
const { keyByAuthKeyOrIp } = require('./app/middleware/rate-limit-key.js');
const v1Limiter = rateLimit({
windowMs: Number.isFinite(rateLimitWindowMs) && rateLimitWindowMs > 0
? rateLimitWindowMs
: 15 * 60 * 1000,
max: Number.isFinite(rateLimitMax) && rateLimitMax > 0
? rateLimitMax
: 100,
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false, // no X-RateLimit-* legacy headers
message: { message: 'Too many requests — try again later.' },
keyGenerator: keyByAuthKeyOrIp,
});
app.use('/v1', v1Limiter);
}
// Metrics observer. Mounted BEFORE the router so it sees every
// request that flows through (including 404s). The handler at
// /metrics is exposed inside the router itself.
app.use(metricsMiddleware);
app.use('/', router);
// 404 fallthrough + global error handler. Order matters — these
// must be last so they catch what the router didn't.
app.use(notFound);
app.use(errorHandler);
// Listen port — env-configurable. Defaults to 3000 so the API can be
// started by a non-root user. Bind to 0.0.0.0 for container friendliness.
//
// Note: `parseInt(...) || 3000` would incorrectly coerce a legitimate
// PORT=0 (kernel-pick-a-free-port, used by tests/api/server-boots.test.js
// to avoid colliding with another dev process on :3000) to the 3000
// fallback. Branch explicitly so 0 is honored and only NaN / negatives
// fall through to the default.
const portRaw = parseInt(process.env.PORT, 10);
const port = Number.isFinite(portRaw) && portRaw >= 0 ? portRaw : 3000;
const host = process.env.HOST || '0.0.0.0';
const server = app.listen(port, host, () => {
const addr = server.address();
log.info({ host: addr.address, port: addr.port }, 'Server listening');
});
// ---- graceful shutdown ----
//
// SIGTERM is what `docker stop`, `systemctl stop`, and Kubernetes
// pod-eviction all send. The default behavior is to drop in-flight
// requests + leak pg pool connections. Trap it and drain instead.
//
// Sequence:
// 1. server.close() — stops accepting new connections, lets the
// ones already in flight finish (Node ≥18 honors keep-alive
// headers and waits for the body).
// 2. db.sequelize.close() — drains the pg pool cleanly.
// 3. process.exit(0).
//
// If anything in the drain hangs longer than SHUTDOWN_TIMEOUT_MS
// (default 25s — under most orchestrators' 30s SIGTERM→SIGKILL
// window), we force-exit with code 1. SIGINT (Ctrl-C in dev) follows
// the same path so dev shutdowns aren't dirty either.
// `parseInt(...) || 25_000` accepts negative integers (parseInt('-100')
// = -100 is truthy, so it'd be used as the timeout — setTimeout(-100)
// fires immediately, force-exiting before the drain has a chance to
// run). Guard with the same Number.isFinite + >= 0 check used for
// PORT (#124) so only NaN and negatives fall back to the default.
const shutdownTimeoutRaw = parseInt(process.env.SHUTDOWN_TIMEOUT_MS, 10);
const shutdownTimeoutMs = Number.isFinite(shutdownTimeoutRaw) && shutdownTimeoutRaw >= 0
? shutdownTimeoutRaw
: 25_000;
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) {
return;
}
shuttingDown = true;
log.info({ signal }, 'received shutdown signal, draining');
// Force-exit if drain hangs.
const killer = setTimeout(() => {
log.error({ signal, timeoutMs: shutdownTimeoutMs }, 'drain timeout, force-exiting');
process.exit(1);
}, shutdownTimeoutMs);
killer.unref();
try {
// Stop accepting new connections.
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
log.info('http server closed');
} catch (err) {
log.error({ err }, 'error closing http server');
}
try {
await db.sequelize.close();
log.info('db pool closed');
} catch (err) {
log.error({ err }, 'error closing db pool');
}
log.info('shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));