@medi-car-bd/mcb-shared-auth 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,16 @@ npm install @medi-car-bd/mcb-shared-auth
26
26
  Requires **Express 4** as a peer dependency (`^4.18.2`). The package targets
27
27
  CommonJS and ships type declarations.
28
28
 
29
+ ## Operational helpers
30
+
31
+ Shared so every service behaves the same under an orchestrator:
32
+
33
+ - `validateEnv({ service, required, secrets })` exits at startup naming every missing variable, and in production rejects secrets shorter than 32 characters or obvious placeholders.
34
+ - `healthHandler(service)` is a liveness probe with no dependencies.
35
+ - `readyHandler(service, { database: () => prisma.$queryRaw\`SELECT 1\` })` returns 200 only when every check passes, otherwise 503. List only dependencies the service cannot work without; the response names failed checks but never the underlying error.
36
+ - `installGracefulShutdown(server, [cleanup, ...])` handles SIGTERM/SIGINT by draining the server, running the cleanups, then exiting, with a hard timeout.
37
+ - `verifyAccessToken(token)` is the single access-token check used by `authenticate` and by Socket.IO handshakes.
38
+
29
39
  ## Quick start
30
40
 
31
41
  ### Protect a route
@@ -3,4 +3,15 @@ import jwt from "jsonwebtoken";
3
3
  export type AuthenticatedRequest = Request & {
4
4
  user?: jwt.JwtPayload & Record<string, unknown>;
5
5
  };
6
+ /**
7
+ * Verifies an access token and returns its claims, or throws.
8
+ *
9
+ * Shared by the HTTP middleware and by Socket.IO handshakes so that every
10
+ * entry point applies the same checks. The algorithm, audience and issuer are
11
+ * pinned to what the auth service signs with, so a token minted for another
12
+ * purpose is not accepted. Refresh tokens share the secret, audience and
13
+ * issuer; only `type` distinguishes them, and one must never act as an access
14
+ * token.
15
+ */
16
+ export declare const verifyAccessToken: (token: string) => jwt.JwtPayload & Record<string, unknown>;
6
17
  export declare const authenticate: (req: AuthenticatedRequest, _res: Response, next: NextFunction) => void;
@@ -3,10 +3,37 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.authenticate = void 0;
6
+ exports.authenticate = exports.verifyAccessToken = void 0;
7
7
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
8
8
  const config_1 = require("../config");
9
9
  const utils_1 = require("../utils");
10
+ /**
11
+ * Verifies an access token and returns its claims, or throws.
12
+ *
13
+ * Shared by the HTTP middleware and by Socket.IO handshakes so that every
14
+ * entry point applies the same checks. The algorithm, audience and issuer are
15
+ * pinned to what the auth service signs with, so a token minted for another
16
+ * purpose is not accepted. Refresh tokens share the secret, audience and
17
+ * issuer; only `type` distinguishes them, and one must never act as an access
18
+ * token.
19
+ */
20
+ const verifyAccessToken = (token) => {
21
+ // Fail closed. Verifying against String(undefined) would accept tokens
22
+ // signed with the literal secret "undefined".
23
+ if (!config_1.JWT_SECRET) {
24
+ throw new utils_1.AppError("Authentication is not configured", 500);
25
+ }
26
+ const decoded = jsonwebtoken_1.default.verify(token, config_1.JWT_SECRET, {
27
+ algorithms: ["HS256"],
28
+ audience: "mcb-services",
29
+ issuer: "mcb-auth",
30
+ });
31
+ if (decoded.type !== "access") {
32
+ throw new utils_1.AppError("Invalid or expired token", 401);
33
+ }
34
+ return decoded;
35
+ };
36
+ exports.verifyAccessToken = verifyAccessToken;
10
37
  const authenticate = (req, _res, next) => {
11
38
  const header = req.headers.authorization;
12
39
  const token = (header && header.startsWith("Bearer ") && header.split(" ")[1]) ||
@@ -15,11 +42,13 @@ const authenticate = (req, _res, next) => {
15
42
  return next(new utils_1.AppError("Authentication required", 401));
16
43
  }
17
44
  try {
18
- const decoded = jsonwebtoken_1.default.verify(token, String(config_1.JWT_SECRET));
19
- req.user = decoded;
45
+ req.user = (0, exports.verifyAccessToken)(token);
20
46
  return next();
21
47
  }
22
- catch (_a) {
48
+ catch (error) {
49
+ if (error instanceof utils_1.AppError && error.statusCode === 500) {
50
+ return next(error);
51
+ }
23
52
  return next(new utils_1.AppError("Invalid or expired token", 401));
24
53
  }
25
54
  };
@@ -3,7 +3,7 @@ export declare class AppError extends Error {
3
3
  status: "fail" | "error";
4
4
  isOperational: boolean;
5
5
  errmsg?: string;
6
- code?: number;
6
+ code?: number | string;
7
7
  errors?: Record<string, unknown>;
8
8
  path?: string;
9
9
  value?: string | number;
@@ -0,0 +1,22 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ /**
3
+ * Handlers that let a client expose the auth endpoints under its own prefix
4
+ * (/api/v1/billing/auth/login and so on), so its UI only ever talks to one
5
+ * service. They hold no auth logic: each request goes to mcb-auth-service and
6
+ * its response comes back unchanged, including the status, body and any
7
+ * Set-Cookie headers. Only the auth service issues tokens.
8
+ *
9
+ * The caller's Authorization header and cookies are forwarded as they are, and
10
+ * if the auth service is unreachable or too slow the caller gets a plain 502;
11
+ * the underlying error is logged, never returned, because it can name internal
12
+ * hosts.
13
+ */
14
+ export declare const authProxy: (authServiceUrl: string, options?: {
15
+ timeoutMs?: number;
16
+ }) => {
17
+ register: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
18
+ login: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
19
+ refresh: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
20
+ logout: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
21
+ me: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
22
+ };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.authProxy = void 0;
13
+ const appError_1 = require("./appError");
14
+ const logger_1 = require("./logger");
15
+ const DEFAULT_TIMEOUT_MS = 10000;
16
+ /** Request headers the auth service needs from the original caller. */
17
+ const FORWARDED_HEADERS = [
18
+ "authorization",
19
+ "cookie",
20
+ "user-agent",
21
+ "x-correlation-id",
22
+ ];
23
+ /**
24
+ * Handlers that let a client expose the auth endpoints under its own prefix
25
+ * (/api/v1/billing/auth/login and so on), so its UI only ever talks to one
26
+ * service. They hold no auth logic: each request goes to mcb-auth-service and
27
+ * its response comes back unchanged, including the status, body and any
28
+ * Set-Cookie headers. Only the auth service issues tokens.
29
+ *
30
+ * The caller's Authorization header and cookies are forwarded as they are, and
31
+ * if the auth service is unreachable or too slow the caller gets a plain 502;
32
+ * the underlying error is logged, never returned, because it can name internal
33
+ * hosts.
34
+ */
35
+ const authProxy = (authServiceUrl, options = {}) => {
36
+ var _a;
37
+ const base = authServiceUrl.replace(/\/+$/, "");
38
+ const timeoutMs = (_a = options.timeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_TIMEOUT_MS;
39
+ const forward = (endpoint, method) => (req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
40
+ var _a;
41
+ const headers = {
42
+ "content-type": "application/json",
43
+ };
44
+ for (const name of FORWARDED_HEADERS) {
45
+ const value = req.headers[name];
46
+ if (typeof value === "string")
47
+ headers[name] = value;
48
+ }
49
+ let upstream;
50
+ let text;
51
+ try {
52
+ upstream = yield fetch(`${base}/api/v1/auth/auth/${endpoint}`, {
53
+ method,
54
+ headers,
55
+ body: method === "POST" ? JSON.stringify((_a = req.body) !== null && _a !== void 0 ? _a : {}) : undefined,
56
+ signal: AbortSignal.timeout(timeoutMs),
57
+ });
58
+ text = yield upstream.text();
59
+ }
60
+ catch (error) {
61
+ logger_1.logger.error(`Auth service request failed (${endpoint}): ${error instanceof Error ? error.message : String(error)}`);
62
+ return next(new appError_1.AppError("Auth service unavailable", 502));
63
+ }
64
+ const contentType = upstream.headers.get("content-type");
65
+ if (contentType)
66
+ res.setHeader("content-type", contentType);
67
+ const cookies = upstream.headers.getSetCookie();
68
+ if (cookies.length > 0)
69
+ res.setHeader("set-cookie", cookies);
70
+ return res.status(upstream.status).send(text);
71
+ });
72
+ return {
73
+ register: forward("register", "POST"),
74
+ login: forward("login", "POST"),
75
+ refresh: forward("refresh", "POST"),
76
+ logout: forward("logout", "POST"),
77
+ me: forward("me", "GET"),
78
+ };
79
+ };
80
+ exports.authProxy = authProxy;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Browser access rules shared by every service, so each UI can call its own
3
+ * client directly (for example the billing UI on :3001 calling the billing
4
+ * client on :8081) and nothing else can.
5
+ *
6
+ * Note that `cors({ origin: ["*"] })` does NOT allow everything: inside a list
7
+ * the cors package treats "*" as a literal origin, which matches no browser.
8
+ * That is what every service had before, and why direct UI calls failed.
9
+ */
10
+ /**
11
+ * Reads a comma-separated origin list (normally the CORS_ORIGINS variable),
12
+ * falling back to `defaults` when it is unset or empty. Origins are compared
13
+ * exactly, so they must include scheme and port and have no trailing slash.
14
+ */
15
+ export declare const parseOrigins: (raw: string | undefined, defaults?: string[]) => string[];
16
+ /**
17
+ * Options for the `cors` middleware (and the same shape works for Socket.IO's
18
+ * `cors` setting). Requests without an Origin header
19
+ * (server-to-server calls, curl, health checks) are unaffected; a browser
20
+ * request from an origin not in the list gets no CORS headers and is blocked
21
+ * by the browser.
22
+ */
23
+ export type BrowserAccess = {
24
+ origin: string[] | false;
25
+ credentials: boolean;
26
+ };
27
+ export declare const corsOptions: (origins: string[]) => BrowserAccess;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.corsOptions = exports.parseOrigins = void 0;
4
+ const logger_1 = require("./logger");
5
+ /**
6
+ * Browser access rules shared by every service, so each UI can call its own
7
+ * client directly (for example the billing UI on :3001 calling the billing
8
+ * client on :8081) and nothing else can.
9
+ *
10
+ * Note that `cors({ origin: ["*"] })` does NOT allow everything: inside a list
11
+ * the cors package treats "*" as a literal origin, which matches no browser.
12
+ * That is what every service had before, and why direct UI calls failed.
13
+ */
14
+ /**
15
+ * Reads a comma-separated origin list (normally the CORS_ORIGINS variable),
16
+ * falling back to `defaults` when it is unset or empty. Origins are compared
17
+ * exactly, so they must include scheme and port and have no trailing slash.
18
+ */
19
+ const parseOrigins = (raw, defaults = []) => {
20
+ const listed = (raw !== null && raw !== void 0 ? raw : "")
21
+ .split(",")
22
+ .map((origin) => origin.trim().replace(/\/+$/, ""))
23
+ .filter(Boolean);
24
+ const origins = listed.length > 0 ? listed : defaults;
25
+ if (origins.includes("*")) {
26
+ // A wildcard cannot be combined with credentials, and in production it
27
+ // would let any website call the API from a signed-in user's browser.
28
+ logger_1.logger.warn('CORS_ORIGINS contains "*", which is ignored. List the UI origins explicitly.');
29
+ }
30
+ return origins.filter((origin) => origin !== "*");
31
+ };
32
+ exports.parseOrigins = parseOrigins;
33
+ const corsOptions = (origins) => ({
34
+ origin: origins.length > 0 ? origins : false,
35
+ credentials: true,
36
+ });
37
+ exports.corsOptions = corsOptions;
@@ -29,6 +29,25 @@ const errorHandler = (err, req, res, _next) => {
29
29
  let statusCode = err.statusCode || 500;
30
30
  const status = err.status || "error";
31
31
  let { message } = err;
32
+ // Prisma rejecting the request itself is the caller's fault, not a server
33
+ // fault: unknown or mistyped fields, a reference to a missing record, or a
34
+ // record that no longer exists. Without this they all surfaced as 500s.
35
+ if (err.name === "PrismaClientValidationError") {
36
+ statusCode = 400;
37
+ message = "Invalid request: unknown or invalid fields";
38
+ }
39
+ else if (err.code === "P2002") {
40
+ statusCode = 409;
41
+ message = "A record with this value already exists";
42
+ }
43
+ else if (err.code === "P2003") {
44
+ statusCode = 400;
45
+ message = "Invalid request: it refers to a record that does not exist";
46
+ }
47
+ else if (err.code === "P2025") {
48
+ statusCode = 404;
49
+ message = "Not found";
50
+ }
32
51
  if (err.code === 11000) {
33
52
  statusCode = 400;
34
53
  const value = ((_b = (_a = err.errmsg) === null || _a === void 0 ? void 0 : _a.match(/(["'])(?:\\.|[^\\])*?\1/)) === null || _b === void 0 ? void 0 : _b[0]) || "value";
@@ -69,11 +88,9 @@ const errorHandler = (err, req, res, _next) => {
69
88
  default:
70
89
  break;
71
90
  }
72
- res.status(statusCode).json({
73
- state: status,
74
- message,
75
- stack: err.stack,
76
- });
91
+ // Stack traces and internal error text are a disclosure risk in production.
92
+ const isProduction = process.env.NODE_ENV === "production";
93
+ res.status(statusCode).json(Object.assign({ state: status, message: isProduction && statusCode >= 500 ? "Internal server error" : message }, (isProduction ? {} : { stack: err.stack })));
77
94
  _1.logger.error(`${statusCode} - ${status} - ${req.originalUrl} - ${req.method} - ${req.ip} - ${correlationId} - ${err.message} - ${err.stack} `);
78
95
  };
79
96
  exports.errorHandler = errorHandler;
@@ -11,3 +11,6 @@ export * from "./rotateRefreshToken";
11
11
  export * from "./generateAccessToken";
12
12
  export * from "./hashToken";
13
13
  export * from "./generateTokenId";
14
+ export * from "./ops";
15
+ export * from "./cors";
16
+ export * from "./authProxy";
@@ -27,3 +27,6 @@ __exportStar(require("./rotateRefreshToken"), exports);
27
27
  __exportStar(require("./generateAccessToken"), exports);
28
28
  __exportStar(require("./hashToken"), exports);
29
29
  __exportStar(require("./generateTokenId"), exports);
30
+ __exportStar(require("./ops"), exports);
31
+ __exportStar(require("./cors"), exports);
32
+ __exportStar(require("./authProxy"), exports);
@@ -28,6 +28,12 @@ exports.logger = (0, winston_1.createLogger)({
28
28
  }),
29
29
  ],
30
30
  });
31
+ // Containers are expected to log to stdout: files inside a container are lost
32
+ // when it is replaced and invisible to `docker logs` and log shippers. The
33
+ // files above are kept for hosts that are not containerised.
34
+ if (config_1.NODE_ENV === "production") {
35
+ exports.logger.add(new winston_1.transports.Console({ format: winston_1.format.json() }));
36
+ }
31
37
  if (config_1.NODE_ENV !== "production") {
32
38
  exports.logger.add(new winston_1.transports.Console({
33
39
  format: winston_1.format.combine(winston_1.format.colorize(), winston_1.format.timestamp({
@@ -0,0 +1,38 @@
1
+ import type { Server } from "http";
2
+ import type { Request, Response } from "express";
3
+ export type ValidateEnvOptions = {
4
+ service: string;
5
+ /** Variables that must be set and non-empty. */
6
+ required: string[];
7
+ /** Subset of variables holding secrets; checked for strength in production. */
8
+ secrets?: string[];
9
+ };
10
+ /**
11
+ * Returns a list of problems with the process environment, empty when it is
12
+ * fine. Pure, so it can be tested without exiting the process.
13
+ */
14
+ export declare const findEnvProblems: ({ required, secrets }: ValidateEnvOptions, env?: NodeJS.ProcessEnv) => string[];
15
+ /**
16
+ * Fails fast at startup. A service that boots with a missing secret otherwise
17
+ * looks healthy and then fails on every request, which is much harder to spot.
18
+ */
19
+ export declare const validateEnv: (options: ValidateEnvOptions) => void;
20
+ /** Liveness: the process is up and the event loop is responding. No dependencies. */
21
+ export declare const healthHandler: (service: string) => (_req: Request, res: Response) => void;
22
+ export type ReadinessChecks = Record<string, () => Promise<unknown>>;
23
+ /**
24
+ * Readiness: 200 only when every check passes, otherwise 503. Only list
25
+ * dependencies the service cannot work without (its database). Optional ones
26
+ * such as Redis or Kafka must not appear here, or an outage of a nice-to-have
27
+ * would take the service out of rotation.
28
+ *
29
+ * The body names which checks failed but never the underlying error, which can
30
+ * contain hostnames and credentials.
31
+ */
32
+ export declare const readyHandler: (service: string, checks: ReadinessChecks, timeoutMs?: number) => (_req: Request, res: Response) => Promise<void>;
33
+ /**
34
+ * Stops accepting connections on SIGTERM/SIGINT, lets in-flight requests
35
+ * finish, runs the cleanups (close the database, etc.), then exits. A hard
36
+ * timeout guarantees the process cannot hang a rolling deploy.
37
+ */
38
+ export declare const installGracefulShutdown: (server: Server, cleanups?: Array<() => Promise<unknown> | unknown>, timeoutMs?: number) => void;
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.installGracefulShutdown = exports.readyHandler = exports.healthHandler = exports.validateEnv = exports.findEnvProblems = void 0;
13
+ const logger_1 = require("./logger");
14
+ /**
15
+ * Operational helpers shared by every service: startup env validation,
16
+ * liveness and readiness handlers, and graceful shutdown. They live here so the
17
+ * services behave identically under an orchestrator instead of each growing its
18
+ * own slightly different copy.
19
+ */
20
+ const PLACEHOLDER_SECRETS = new Set([
21
+ "secret",
22
+ "changeme",
23
+ "change-me",
24
+ "change_me",
25
+ "password",
26
+ "undefined",
27
+ "jwt_secret",
28
+ ]);
29
+ const MIN_PRODUCTION_SECRET_LENGTH = 32;
30
+ /**
31
+ * Returns a list of problems with the process environment, empty when it is
32
+ * fine. Pure, so it can be tested without exiting the process.
33
+ */
34
+ const findEnvProblems = ({ required, secrets = [] }, env = process.env) => {
35
+ var _a, _b, _c;
36
+ const problems = [];
37
+ for (const name of required) {
38
+ if (!((_a = env[name]) === null || _a === void 0 ? void 0 : _a.trim()))
39
+ problems.push(`${name} is not set`);
40
+ }
41
+ const port = (_b = env.PORT) === null || _b === void 0 ? void 0 : _b.trim();
42
+ if (port && !/^\d+$/.test(port))
43
+ problems.push("PORT must be a number (it is not, so the server would bind to a random port)");
44
+ if (env.NODE_ENV === "production") {
45
+ for (const name of secrets) {
46
+ const value = (_c = env[name]) === null || _c === void 0 ? void 0 : _c.trim();
47
+ if (!value)
48
+ continue; // already reported as missing when required
49
+ if (value.length < MIN_PRODUCTION_SECRET_LENGTH ||
50
+ PLACEHOLDER_SECRETS.has(value.toLowerCase())) {
51
+ problems.push(`${name} is too weak for production (use at least ${MIN_PRODUCTION_SECRET_LENGTH} random characters)`);
52
+ }
53
+ }
54
+ }
55
+ return problems;
56
+ };
57
+ exports.findEnvProblems = findEnvProblems;
58
+ /**
59
+ * Fails fast at startup. A service that boots with a missing secret otherwise
60
+ * looks healthy and then fails on every request, which is much harder to spot.
61
+ */
62
+ const validateEnv = (options) => {
63
+ const problems = (0, exports.findEnvProblems)(options);
64
+ if (problems.length === 0)
65
+ return;
66
+ const message = `${options.service} cannot start:\n${problems
67
+ .map((p) => ` - ${p}`)
68
+ .join("\n")}`;
69
+ logger_1.logger.error(message);
70
+ console.error(message);
71
+ process.exit(1);
72
+ };
73
+ exports.validateEnv = validateEnv;
74
+ /** Liveness: the process is up and the event loop is responding. No dependencies. */
75
+ const healthHandler = (service) => (_req, res) => {
76
+ res.status(200).json({
77
+ status: "ok",
78
+ service,
79
+ uptimeSeconds: Math.round(process.uptime()),
80
+ });
81
+ };
82
+ exports.healthHandler = healthHandler;
83
+ const withTimeout = (work, ms) => new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => reject(new Error("timeout")), ms);
85
+ work.then((value) => {
86
+ clearTimeout(timer);
87
+ resolve(value);
88
+ }, (error) => {
89
+ clearTimeout(timer);
90
+ reject(error);
91
+ });
92
+ });
93
+ /**
94
+ * Readiness: 200 only when every check passes, otherwise 503. Only list
95
+ * dependencies the service cannot work without (its database). Optional ones
96
+ * such as Redis or Kafka must not appear here, or an outage of a nice-to-have
97
+ * would take the service out of rotation.
98
+ *
99
+ * The body names which checks failed but never the underlying error, which can
100
+ * contain hostnames and credentials.
101
+ */
102
+ const readyHandler = (service, checks, timeoutMs = 2000) => (_req, res) => __awaiter(void 0, void 0, void 0, function* () {
103
+ const results = {};
104
+ yield Promise.all(Object.entries(checks).map((_a) => __awaiter(void 0, [_a], void 0, function* ([name, check]) {
105
+ try {
106
+ yield withTimeout(check(), timeoutMs);
107
+ results[name] = "up";
108
+ }
109
+ catch (error) {
110
+ results[name] = "down";
111
+ logger_1.logger.warn(`${service} readiness check "${name}" failed: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ })));
114
+ const ready = Object.values(results).every((r) => r === "up");
115
+ res.status(ready ? 200 : 503).json({
116
+ status: ready ? "ready" : "unavailable",
117
+ service,
118
+ checks: results,
119
+ });
120
+ });
121
+ exports.readyHandler = readyHandler;
122
+ /**
123
+ * Stops accepting connections on SIGTERM/SIGINT, lets in-flight requests
124
+ * finish, runs the cleanups (close the database, etc.), then exits. A hard
125
+ * timeout guarantees the process cannot hang a rolling deploy.
126
+ */
127
+ const installGracefulShutdown = (server, cleanups = [], timeoutMs = 10000) => {
128
+ let shuttingDown = false;
129
+ const shutdown = (signal) => {
130
+ var _a;
131
+ if (shuttingDown)
132
+ return;
133
+ shuttingDown = true;
134
+ logger_1.logger.info(`Received ${signal}, shutting down`);
135
+ const force = setTimeout(() => {
136
+ logger_1.logger.error("Graceful shutdown timed out, forcing exit");
137
+ process.exit(1);
138
+ }, timeoutMs);
139
+ force.unref();
140
+ server.close(() => {
141
+ void Promise.allSettled(cleanups.map((fn) => Promise.resolve().then(fn))).then((results) => {
142
+ results.forEach((r) => {
143
+ if (r.status === "rejected")
144
+ logger_1.logger.warn(`Shutdown cleanup failed: ${String(r.reason)}`);
145
+ });
146
+ process.exit(0);
147
+ });
148
+ });
149
+ // Idle keep-alive connections would otherwise hold server.close() open.
150
+ (_a = server.closeIdleConnections) === null || _a === void 0 ? void 0 : _a.call(server);
151
+ };
152
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
153
+ process.on("SIGINT", () => shutdown("SIGINT"));
154
+ };
155
+ exports.installGracefulShutdown = installGracefulShutdown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medi-car-bd/mcb-shared-auth",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Shared authentication module for MCB applications",
5
5
  "packageManager": "pnpm@10.18.0",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  "lint": "eslint . --ext .ts,.tsx,.js,.jsx",
18
18
  "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
19
19
  "format": "prettier --write .",
20
- "format:check": "prettier --check ."
20
+ "format:check": "prettier --check .",
21
+ "test": "tsc -p tsconfig.json && JWT_SECRET=unit-test-secret node --test dist/test/*.test.js"
21
22
  },
22
23
  "keywords": [
23
24
  "jwt",
@@ -55,7 +56,8 @@
55
56
  "express": "^4.18.2"
56
57
  },
57
58
  "files": [
58
- "dist"
59
+ "dist",
60
+ "!dist/test"
59
61
  ],
60
62
  "lint-staged": {
61
63
  "*.{ts,tsx,js,jsx}": [