@tumbaland/backend-core 1.17.0 → 1.19.0

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.
Files changed (53) hide show
  1. package/README.md +52 -0
  2. package/dist/app/shutdown.d.ts +31 -0
  3. package/dist/app/shutdown.d.ts.map +1 -0
  4. package/dist/app/shutdown.js +58 -0
  5. package/dist/app/shutdown.js.map +1 -0
  6. package/dist/health/createHealthCheck.d.ts +32 -0
  7. package/dist/health/createHealthCheck.d.ts.map +1 -0
  8. package/dist/health/createHealthCheck.js +50 -0
  9. package/dist/health/createHealthCheck.js.map +1 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +9 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/logging/logger.js +1 -1
  15. package/dist/logging/logger.js.map +1 -1
  16. package/dist/metrics/index.d.ts +3 -2
  17. package/dist/metrics/index.d.ts.map +1 -1
  18. package/dist/metrics/index.js +1 -1
  19. package/dist/metrics/index.js.map +1 -1
  20. package/dist/middleware/errorHandler.js.map +1 -1
  21. package/dist/middleware/requestLogger.d.ts +3 -2
  22. package/dist/middleware/requestLogger.d.ts.map +1 -1
  23. package/dist/middleware/requestLogger.js.map +1 -1
  24. package/dist/tracing/index.d.ts +9 -8
  25. package/dist/tracing/index.d.ts.map +1 -1
  26. package/dist/tracing/index.js +7 -7
  27. package/dist/tracing/index.js.map +1 -1
  28. package/dist/types/auth.d.ts +9 -1
  29. package/dist/types/auth.d.ts.map +1 -1
  30. package/dist/utils/correlation.js.map +1 -1
  31. package/dist/utils/permissionUtils.d.ts +31 -0
  32. package/dist/utils/permissionUtils.d.ts.map +1 -0
  33. package/dist/utils/permissionUtils.js +53 -0
  34. package/dist/utils/permissionUtils.js.map +1 -0
  35. package/dist/utils/response.d.ts +3 -3
  36. package/dist/utils/response.d.ts.map +1 -1
  37. package/package.json +3 -3
  38. package/src/app/shutdown.test.ts +129 -0
  39. package/src/app/shutdown.ts +81 -0
  40. package/src/health/createHealthCheck.test.ts +89 -0
  41. package/src/health/createHealthCheck.ts +67 -0
  42. package/src/index.ts +5 -0
  43. package/src/logging/logger.ts +1 -1
  44. package/src/metrics/index.ts +3 -2
  45. package/src/middleware/errorHandler.ts +2 -2
  46. package/src/middleware/requestLogger.ts +2 -1
  47. package/src/tracing/index.test.ts +11 -7
  48. package/src/tracing/index.ts +26 -13
  49. package/src/types/auth.ts +10 -1
  50. package/src/utils/correlation.ts +1 -1
  51. package/src/utils/permissionUtils.test.ts +47 -0
  52. package/src/utils/permissionUtils.ts +68 -0
  53. package/src/utils/response.ts +3 -3
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Simple permission helper that builds query filters for group-based access
3
+ * This avoids external API calls and can be used in database queries directly
4
+ */
5
+ /**
6
+ * Build a MongoDB query for resources accessible by a user
7
+ * @param userId - The user's ID
8
+ * @param groupIds - Array of group IDs the user belongs to (optional)
9
+ * @param includePublic - Whether to include public resources
10
+ */
11
+ export declare function buildAccessQuery(userId: string, groupIds?: string[], includePublic?: boolean): {
12
+ $or: ({
13
+ userId: string;
14
+ } | {
15
+ groupId: {
16
+ $in: string[];
17
+ };
18
+ } | {
19
+ isPublic: boolean;
20
+ })[];
21
+ };
22
+ /**
23
+ * Check if a user can access a specific resource
24
+ * @param userId - The requesting user's ID
25
+ * @param resourceUserId - The resource owner's ID
26
+ * @param resourceGroupId - The resource's group ID (if any)
27
+ * @param userGroupIds - Array of group IDs the user belongs to
28
+ * @param isResourcePublic - Whether the resource is public
29
+ */
30
+ export declare function canAccessResource(userId: string, resourceUserId: string, resourceGroupId?: string, userGroupIds?: string[], isResourcePublic?: boolean): boolean;
31
+ //# sourceMappingURL=permissionUtils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissionUtils.d.ts","sourceRoot":"","sources":["../../src/utils/permissionUtils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,QAAQ,GAAE,MAAM,EAAO,EACvB,aAAa,GAAE,OAAe;;gBAGlB,MAAM;;iBAAgB;YAAE,GAAG,EAAE,MAAM,EAAE,CAAA;SAAE;;kBAAiB,OAAO;;EAgB5E;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,eAAe,CAAC,EAAE,MAAM,EACxB,YAAY,GAAE,MAAM,EAAO,EAC3B,gBAAgB,GAAE,OAAe,GAChC,OAAO,CAkBT"}
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /**
3
+ * Simple permission helper that builds query filters for group-based access
4
+ * This avoids external API calls and can be used in database queries directly
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.buildAccessQuery = buildAccessQuery;
8
+ exports.canAccessResource = canAccessResource;
9
+ /**
10
+ * Build a MongoDB query for resources accessible by a user
11
+ * @param userId - The user's ID
12
+ * @param groupIds - Array of group IDs the user belongs to (optional)
13
+ * @param includePublic - Whether to include public resources
14
+ */
15
+ function buildAccessQuery(userId, groupIds = [], includePublic = false) {
16
+ const conditions = [
17
+ { userId } // User's own resources
18
+ ];
19
+ // Add group-based access
20
+ if (groupIds.length > 0) {
21
+ conditions.push({ groupId: { $in: groupIds } });
22
+ }
23
+ // Add public resources if requested
24
+ if (includePublic) {
25
+ conditions.push({ isPublic: true });
26
+ }
27
+ return { $or: conditions };
28
+ }
29
+ /**
30
+ * Check if a user can access a specific resource
31
+ * @param userId - The requesting user's ID
32
+ * @param resourceUserId - The resource owner's ID
33
+ * @param resourceGroupId - The resource's group ID (if any)
34
+ * @param userGroupIds - Array of group IDs the user belongs to
35
+ * @param isResourcePublic - Whether the resource is public
36
+ */
37
+ function canAccessResource(userId, resourceUserId, resourceGroupId, userGroupIds = [], isResourcePublic = false) {
38
+ // Owner can always access their own resources
39
+ if (userId === resourceUserId) {
40
+ return true;
41
+ }
42
+ // Public resources are accessible to anyone
43
+ if (isResourcePublic) {
44
+ return true;
45
+ }
46
+ // If resource is associated with a group, check if user is in that group
47
+ if (resourceGroupId && userGroupIds.includes(resourceGroupId)) {
48
+ return true;
49
+ }
50
+ // No access
51
+ return false;
52
+ }
53
+ //# sourceMappingURL=permissionUtils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissionUtils.js","sourceRoot":"","sources":["../../src/utils/permissionUtils.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAQH,4CAsBC;AAUD,8CAwBC;AA9DD;;;;;GAKG;AACH,SAAgB,gBAAgB,CAC9B,MAAc,EACd,WAAqB,EAAE,EACvB,gBAAyB,KAAK;IAE9B,MAAM,UAAU,GAEZ;QACF,EAAE,MAAM,EAAE,CAAC,uBAAuB;KACnC,CAAC;IAEF,yBAAyB;IACzB,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,UAAU,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;IAClD,CAAC;IAED,oCAAoC;IACpC,IAAI,aAAa,EAAE,CAAC;QAClB,UAAU,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,CAAC;IAED,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;AAC7B,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,iBAAiB,CAC/B,MAAc,EACd,cAAsB,EACtB,eAAwB,EACxB,eAAyB,EAAE,EAC3B,mBAA4B,KAAK;IAEjC,8CAA8C;IAC9C,IAAI,MAAM,KAAK,cAAc,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,4CAA4C;IAC5C,IAAI,gBAAgB,EAAE,CAAC;QACrB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,yEAAyE;IACzE,IAAI,eAAe,IAAI,YAAY,CAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC;QAC9D,OAAO,IAAI,CAAC;IACd,CAAC;IAED,YAAY;IACZ,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -2,7 +2,7 @@ import { Response } from 'express';
2
2
  /**
3
3
  * Standard API response format
4
4
  */
5
- export interface ApiResponse<T = any> {
5
+ export interface ApiResponse<T = unknown> {
6
6
  success: boolean;
7
7
  data?: T;
8
8
  message?: string;
@@ -12,11 +12,11 @@ export interface ApiResponse<T = any> {
12
12
  /**
13
13
  * Create a standardized API response
14
14
  */
15
- export declare function createApiResponse<T = any>(success: boolean, data?: T, message?: string, error?: string, correlationId?: string): ApiResponse<T>;
15
+ export declare function createApiResponse<T = unknown>(success: boolean, data?: T, message?: string, error?: string, correlationId?: string): ApiResponse<T>;
16
16
  /**
17
17
  * Send a success response
18
18
  */
19
- export declare function sendSuccess<T = any>(res: Response, data?: T, message?: string, statusCode?: number, correlationId?: string): void;
19
+ export declare function sendSuccess<T = unknown>(res: Response, data?: T, message?: string, statusCode?: number, correlationId?: string): void;
20
20
  /**
21
21
  * Send an error response
22
22
  */
@@ -1 +1 @@
1
- {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/utils/response.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC;;GAEG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,GAAG,GAAG,EACvC,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,CAAC,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,GACrB,WAAW,CAAC,CAAC,CAAC,CAShB;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,CAAC,GAAG,GAAG,EACjC,GAAG,EAAE,QAAQ,EACb,IAAI,CAAC,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,GAAE,MAAY,EACxB,aAAa,CAAC,EAAE,MAAM,QAIvB;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,MAAY,EACxB,KAAK,CAAC,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,QAIvB"}
1
+ {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/utils/response.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC;;GAEG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,OAAO;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,GAAG,OAAO,EAC3C,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,CAAC,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,GACrB,WAAW,CAAC,CAAC,CAAC,CAShB;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,CAAC,GAAG,OAAO,EACrC,GAAG,EAAE,QAAQ,EACb,IAAI,CAAC,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,GAAE,MAAY,EACxB,aAAa,CAAC,EAAE,MAAM,QAIvB;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,MAAY,EACxB,KAAK,CAAC,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,QAIvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "@opentelemetry/semantic-conventions": "^1.41.1",
47
47
  "cookie-parser": "^1.4.7",
48
48
  "cors": "^2.8.6",
49
- "express": "^5.1.0",
49
+ "express": "^5.2.1",
50
50
  "express-rate-limit": "^8.5.2",
51
51
  "helmet": "^8.0.0",
52
52
  "jsonwebtoken": "^9.0.3",
@@ -58,7 +58,7 @@
58
58
  "zod": "^4.4.3"
59
59
  },
60
60
  "peerDependencies": {
61
- "express": "^5.1.0",
61
+ "express": "^5.2.1",
62
62
  "mongoose": "^9.7.3"
63
63
  }
64
64
  }
@@ -0,0 +1,129 @@
1
+ jest.mock('../logging/logger', () => ({
2
+ __esModule: true,
3
+ default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
+ }));
5
+
6
+ jest.mock('../database/connection', () => ({
7
+ __esModule: true,
8
+ disconnectDB: jest.fn().mockResolvedValue(undefined)
9
+ }));
10
+
11
+ import type { Server } from 'http';
12
+ import logger from '../logging/logger';
13
+ import { disconnectDB } from '../database/connection';
14
+ import { registerShutdown } from './shutdown';
15
+
16
+ /** Minimal fake http.Server whose close() invokes its callback. */
17
+ function fakeServer(closeErr?: Error): Server {
18
+ return {
19
+ close: jest.fn((cb?: (err?: Error) => void) => {
20
+ cb?.(closeErr);
21
+ return undefined as unknown as Server;
22
+ })
23
+ } as unknown as Server;
24
+ }
25
+
26
+ describe('registerShutdown', () => {
27
+ const listeners: Record<string, (...args: unknown[]) => void> = {};
28
+ let processOnSpy: jest.SpyInstance;
29
+ let exitSpy: jest.SpyInstance;
30
+
31
+ beforeEach(() => {
32
+ jest.clearAllMocks();
33
+ for (const key of Object.keys(listeners)) delete listeners[key];
34
+
35
+ processOnSpy = jest
36
+ .spyOn(process, 'on')
37
+ .mockImplementation((event: string | symbol, handler: (...args: unknown[]) => void) => {
38
+ listeners[event as string] = handler;
39
+ return process;
40
+ });
41
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation(((): never => undefined as never));
42
+ });
43
+
44
+ afterEach(() => {
45
+ processOnSpy.mockRestore();
46
+ exitSpy.mockRestore();
47
+ });
48
+
49
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
50
+
51
+ it('registers SIGINT and SIGTERM handlers', () => {
52
+ registerShutdown({ serviceName: 'svc' });
53
+ expect(Object.keys(listeners).sort()).toEqual(['SIGINT', 'SIGTERM']);
54
+ });
55
+
56
+ it('closes the server, disconnects the DB, and exits 0 on SIGTERM', async () => {
57
+ const server = fakeServer();
58
+ registerShutdown({ serviceName: 'svc', server });
59
+
60
+ listeners.SIGTERM();
61
+ await flush();
62
+
63
+ expect(server.close).toHaveBeenCalled();
64
+ expect(disconnectDB).toHaveBeenCalledWith('svc');
65
+ expect(exitSpy).toHaveBeenCalledWith(0);
66
+ expect(logger.info).toHaveBeenCalledWith('Received shutdown signal, closing gracefully', {
67
+ service: 'svc',
68
+ signal: 'SIGTERM'
69
+ });
70
+ });
71
+
72
+ it('runs the onShutdown hook before closing the server', async () => {
73
+ const order: string[] = [];
74
+ const server = {
75
+ close: jest.fn((cb?: (err?: Error) => void) => {
76
+ order.push('server');
77
+ cb?.();
78
+ return undefined as unknown as Server;
79
+ })
80
+ } as unknown as Server;
81
+ const onShutdown = jest.fn(async () => {
82
+ order.push('hook');
83
+ });
84
+
85
+ registerShutdown({ serviceName: 'svc', server, onShutdown });
86
+ listeners.SIGINT();
87
+ await flush();
88
+
89
+ expect(order).toEqual(['hook', 'server']);
90
+ expect(exitSpy).toHaveBeenCalledWith(0);
91
+ });
92
+
93
+ it('skips the DB disconnect when disconnectDatabase is false', async () => {
94
+ registerShutdown({ serviceName: 'svc', disconnectDatabase: false });
95
+
96
+ listeners.SIGTERM();
97
+ await flush();
98
+
99
+ expect(disconnectDB).not.toHaveBeenCalled();
100
+ expect(exitSpy).toHaveBeenCalledWith(0);
101
+ });
102
+
103
+ it('is idempotent — a second signal while shutting down is ignored', async () => {
104
+ const server = fakeServer();
105
+ registerShutdown({ serviceName: 'svc', server });
106
+
107
+ listeners.SIGTERM();
108
+ listeners.SIGINT();
109
+ await flush();
110
+
111
+ expect(server.close).toHaveBeenCalledTimes(1);
112
+ expect(exitSpy).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('exits 1 when the server fails to close', async () => {
116
+ const server = fakeServer(new Error('close boom'));
117
+ registerShutdown({ serviceName: 'svc', server });
118
+
119
+ listeners.SIGTERM();
120
+ await flush();
121
+
122
+ expect(disconnectDB).not.toHaveBeenCalled();
123
+ expect(exitSpy).toHaveBeenCalledWith(1);
124
+ expect(logger.error).toHaveBeenCalledWith('Error during graceful shutdown', {
125
+ service: 'svc',
126
+ error: 'close boom'
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,81 @@
1
+ import type { Server } from 'http';
2
+ import logger from '../logging/logger';
3
+ import { disconnectDB } from '../database/connection';
4
+
5
+ export interface RegisterShutdownOptions {
6
+ /** Service name, used for log context and the DB disconnect. */
7
+ serviceName: string;
8
+ /** The HTTP server returned by `app.listen(...)`, closed before the DB. */
9
+ server?: Server;
10
+ /**
11
+ * Whether to close the MongoDB connection on shutdown. Defaults to `true`;
12
+ * set `false` for services that never call `connectDB` (e.g. file, public).
13
+ */
14
+ disconnectDatabase?: boolean;
15
+ /** Optional extra cleanup run before the server/DB are closed. */
16
+ onShutdown?: () => Promise<void> | void;
17
+ /**
18
+ * Force `process.exit` after this many ms if a graceful close hangs, so a
19
+ * stuck connection can't block a container rollout. Defaults to 10s.
20
+ */
21
+ forceExitAfterMs?: number;
22
+ }
23
+
24
+ /**
25
+ * Registers `SIGINT` and `SIGTERM` handlers that drain the service before the
26
+ * process exits: run the optional cleanup hook, stop accepting new HTTP
27
+ * connections, then close the MongoDB connection. `SIGTERM` matters most —
28
+ * it's the signal Docker/Kubernetes send on stop and rollout, and without a
29
+ * handler the process is force-killed with its DB connection still open.
30
+ *
31
+ * The handler is idempotent (a second signal while shutting down is ignored)
32
+ * and self-arms a force-exit timer so a hung close still terminates.
33
+ */
34
+ export const registerShutdown = ({
35
+ serviceName,
36
+ server,
37
+ disconnectDatabase = true,
38
+ onShutdown,
39
+ forceExitAfterMs = 10_000
40
+ }: RegisterShutdownOptions): void => {
41
+ let shuttingDown = false;
42
+
43
+ const shutdown = async (signal: string): Promise<void> => {
44
+ if (shuttingDown) return;
45
+ shuttingDown = true;
46
+
47
+ logger.info('Received shutdown signal, closing gracefully', { service: serviceName, signal });
48
+
49
+ const forceExit = setTimeout(() => {
50
+ logger.error('Graceful shutdown timed out, forcing exit', { service: serviceName });
51
+ process.exit(1);
52
+ }, forceExitAfterMs);
53
+ // Don't let the timer itself keep the event loop alive.
54
+ forceExit.unref?.();
55
+
56
+ try {
57
+ if (onShutdown) await onShutdown();
58
+
59
+ if (server) {
60
+ await new Promise<void>((resolve, reject) => {
61
+ server.close((err) => (err ? reject(err) : resolve()));
62
+ });
63
+ }
64
+
65
+ if (disconnectDatabase) await disconnectDB(serviceName);
66
+
67
+ clearTimeout(forceExit);
68
+ process.exit(0);
69
+ } catch (error) {
70
+ clearTimeout(forceExit);
71
+ logger.error('Error during graceful shutdown', {
72
+ service: serviceName,
73
+ error: (error as Error)?.message
74
+ });
75
+ process.exit(1);
76
+ }
77
+ };
78
+
79
+ process.on('SIGINT', () => void shutdown('SIGINT'));
80
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
81
+ };
@@ -0,0 +1,89 @@
1
+ import { Request, Response } from 'express';
2
+ import mongoose from 'mongoose';
3
+ import { createHealthCheck } from './createHealthCheck';
4
+
5
+ function mockRes(): Response {
6
+ const res: Partial<Response> = {};
7
+ res.status = jest.fn().mockReturnValue(res);
8
+ res.json = jest.fn().mockReturnValue(res);
9
+ return res as Response;
10
+ }
11
+
12
+ describe('createHealthCheck', () => {
13
+ const config = { service: 'demo-service', description: 'Demo service' };
14
+
15
+ afterEach(() => {
16
+ jest.restoreAllMocks();
17
+ });
18
+
19
+ it('responds 200 with dependencies.mongodb "connected" when Mongo is up', async () => {
20
+ jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as never);
21
+ const res = mockRes();
22
+
23
+ await createHealthCheck(config)({} as Request, res);
24
+
25
+ expect(res.status).toHaveBeenCalledWith(200);
26
+ expect(res.json).toHaveBeenCalledWith(
27
+ expect.objectContaining({
28
+ status: 'ok',
29
+ service: 'demo-service',
30
+ type: 'backend',
31
+ version: '1.0.0',
32
+ description: 'Demo service',
33
+ dependencies: { mongodb: 'connected' }
34
+ })
35
+ );
36
+ });
37
+
38
+ it('responds 503 with dependencies.mongodb "disconnected" when Mongo is down', async () => {
39
+ jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 0 } as never);
40
+ const res = mockRes();
41
+
42
+ await createHealthCheck(config)({} as Request, res);
43
+
44
+ expect(res.status).toHaveBeenCalledWith(503);
45
+ expect(res.json).toHaveBeenCalledWith(
46
+ expect.objectContaining({ status: 'error', dependencies: { mongodb: 'disconnected' } })
47
+ );
48
+ });
49
+
50
+ it('honors an explicit version override', async () => {
51
+ jest.spyOn(mongoose, 'connection', 'get').mockReturnValue({ readyState: 1 } as never);
52
+ const res = mockRes();
53
+
54
+ await createHealthCheck({ ...config, version: '2.3.4' })({} as Request, res);
55
+
56
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ version: '2.3.4' }));
57
+ });
58
+
59
+ it('responds 503 with a generic error body when reading connection state throws', async () => {
60
+ jest.spyOn(mongoose, 'connection', 'get').mockImplementation(() => {
61
+ throw new Error('boom');
62
+ });
63
+ const res = mockRes();
64
+
65
+ await createHealthCheck(config)({} as Request, res);
66
+
67
+ expect(res.status).toHaveBeenCalledWith(503);
68
+ expect(res.json).toHaveBeenCalledWith(
69
+ expect.objectContaining({ status: 'error', service: 'demo-service', error: 'Health check failed' })
70
+ );
71
+ });
72
+
73
+ describe('checkDatabase: false (DB-less services)', () => {
74
+ it('always responds 200 ok without touching Mongo or emitting dependencies', async () => {
75
+ const connectionSpy = jest.spyOn(mongoose, 'connection', 'get');
76
+ const res = mockRes();
77
+
78
+ await createHealthCheck({ ...config, checkDatabase: false })({} as Request, res);
79
+
80
+ expect(connectionSpy).not.toHaveBeenCalled();
81
+ expect(res.status).toHaveBeenCalledWith(200);
82
+ const body = (res.json as jest.Mock).mock.calls[0][0];
83
+ expect(body).toEqual(
84
+ expect.objectContaining({ status: 'ok', service: 'demo-service', type: 'backend', description: 'Demo service' })
85
+ );
86
+ expect(body).not.toHaveProperty('dependencies');
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,67 @@
1
+ import { Request, Response } from 'express';
2
+ import mongoose from 'mongoose';
3
+
4
+ export interface HealthCheckConfig {
5
+ /** Service name reported in the body, e.g. `auth-service`. */
6
+ service: string;
7
+ /** Human-readable service description. */
8
+ description: string;
9
+ /** Reported version. Defaults to `1.0.0`. */
10
+ version?: string;
11
+ /**
12
+ * Whether readiness depends on the MongoDB connection. Defaults to `true`:
13
+ * the response reports `dependencies.mongodb` and returns 503 while
14
+ * disconnected. Set `false` for services with no database (e.g.
15
+ * public-service), which then always report `ok` with no `dependencies`.
16
+ */
17
+ checkDatabase?: boolean;
18
+ }
19
+
20
+ /**
21
+ * The single, unified `/health` readiness handler for every backend service.
22
+ *
23
+ * Response contract (identical across services):
24
+ * { status, service, type: 'backend', timestamp, version, description }
25
+ * plus, when `checkDatabase` is `true` (the default), a
26
+ * `dependencies.mongodb` field and a 503 while the connection is down. A
27
+ * DB-less service (`checkDatabase: false`) omits `dependencies` and always
28
+ * reports `ok`. On an unexpected failure the handler returns 503 with a
29
+ * generic `error: 'Health check failed'` body.
30
+ *
31
+ * `/health/live` (liveness, no dependency checks) is mounted separately by
32
+ * `createBaseApp`.
33
+ */
34
+ export const createHealthCheck = ({
35
+ service,
36
+ description,
37
+ version = '1.0.0',
38
+ checkDatabase = true
39
+ }: HealthCheckConfig) => {
40
+ return async (_req: Request, res: Response): Promise<void> => {
41
+ const timestamp = new Date().toISOString();
42
+ const base = { service, type: 'backend', timestamp, version, description };
43
+
44
+ if (!checkDatabase) {
45
+ res.status(200).json({ status: 'ok', ...base });
46
+ return;
47
+ }
48
+
49
+ try {
50
+ const isMongoHealthy = mongoose.connection.readyState === 1; // 1 = connected
51
+
52
+ res.status(isMongoHealthy ? 200 : 503).json({
53
+ status: isMongoHealthy ? 'ok' : 'error',
54
+ ...base,
55
+ dependencies: {
56
+ mongodb: isMongoHealthy ? 'connected' : 'disconnected'
57
+ }
58
+ });
59
+ } catch {
60
+ res.status(503).json({
61
+ status: 'error',
62
+ ...base,
63
+ error: 'Health check failed'
64
+ });
65
+ }
66
+ };
67
+ };
package/src/index.ts CHANGED
@@ -3,12 +3,16 @@
3
3
  // App bootstrap
4
4
  export { createBaseApp } from './app/createBaseApp';
5
5
  export type { CreateBaseAppOptions } from './app/createBaseApp';
6
+ export { registerShutdown } from './app/shutdown';
7
+ export type { RegisterShutdownOptions } from './app/shutdown';
6
8
 
7
9
  // Logging
8
10
  export { default as logger } from './logging/logger';
9
11
 
10
12
  // Health checks
11
13
  export { healthCheck, healthLive, metricsHandler } from './health/healthController';
14
+ export { createHealthCheck } from './health/createHealthCheck';
15
+ export type { HealthCheckConfig } from './health/createHealthCheck';
12
16
 
13
17
  // Database
14
18
  export { connectDB, disconnectDB } from './database/connection';
@@ -34,6 +38,7 @@ export type { UserPayload } from './types/auth';
34
38
  export { generateCorrelationId, correlationMiddleware } from './utils/correlation';
35
39
  export { createApiResponse, sendSuccess, sendError } from './utils/response';
36
40
  export type { ApiResponse } from './utils/response';
41
+ export { buildAccessQuery, canAccessResource } from './utils/permissionUtils';
37
42
 
38
43
  // Metrics
39
44
  export {
@@ -33,7 +33,7 @@ const consoleFormat = winston.format.combine(
33
33
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
34
34
  winston.format.colorize({ all: true }),
35
35
  winston.format.printf(
36
- (info: any) => `${info.timestamp} ${info.level}: ${info.message}`,
36
+ (info) => `${info.timestamp} ${info.level}: ${String(info.message)}`,
37
37
  ),
38
38
  );
39
39
 
@@ -1,4 +1,5 @@
1
1
  import promClient from 'prom-client';
2
+ import { Request, Response, NextFunction } from 'express';
2
3
 
3
4
  // Create a Registry which registers the metrics
4
5
  const register = new promClient.Registry();
@@ -69,7 +70,7 @@ export const businessMetrics = {
69
70
  };
70
71
 
71
72
  // Metrics endpoint handler
72
- export const metricsHandler = async (req: any, res: any) => {
73
+ export const metricsHandler = async (_req: Request, res: Response) => {
73
74
  try {
74
75
  res.set('Content-Type', register.contentType);
75
76
  const metrics = await register.metrics();
@@ -80,7 +81,7 @@ export const metricsHandler = async (req: any, res: any) => {
80
81
  };
81
82
 
82
83
  // Middleware to collect HTTP metrics
83
- export const metricsMiddleware = (req: any, res: any, next: any) => {
84
+ export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
84
85
  const start = Date.now();
85
86
  const { method, url } = req;
86
87
 
@@ -53,7 +53,7 @@ export const errorHandler: ErrorRequestHandler = (
53
53
  statusCode: known.statusCode,
54
54
  url: req.url,
55
55
  method: req.method,
56
- correlationId: (req as any).correlationId
56
+ correlationId: req.correlationId
57
57
  });
58
58
  } else {
59
59
  logger.error('Unhandled error:', {
@@ -63,7 +63,7 @@ export const errorHandler: ErrorRequestHandler = (
63
63
  method: req.method,
64
64
  ip: req.ip,
65
65
  userAgent: req.get('User-Agent'),
66
- correlationId: (req as any).correlationId
66
+ correlationId: req.correlationId
67
67
  });
68
68
  }
69
69
 
@@ -1,4 +1,5 @@
1
1
  import morgan from 'morgan';
2
+ import { Request, Response, NextFunction } from 'express';
2
3
  import logger from '../logging/logger';
3
4
  import { metricsMiddleware } from '../metrics';
4
5
 
@@ -24,7 +25,7 @@ export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
24
25
  * Simple request logger for development
25
26
  * Logs basic request info with correlation ID
26
27
  */
27
- export const simpleRequestLogger = (req: any, res: any, next: any) => {
28
+ export const simpleRequestLogger = (req: Request, res: Response, next: NextFunction) => {
28
29
  const start = Date.now();
29
30
  const correlationId = req.correlationId || 'unknown';
30
31
 
@@ -6,12 +6,16 @@
6
6
  * resolution, parent-span-vs-context handling, the helper wrappers), using
7
7
  * the real (side-effect-free) @opentelemetry/api for trace/context/propagation.
8
8
  */
9
- const mockSpan = () => ({
10
- setAttribute: jest.fn(),
11
- addEvent: jest.fn(),
12
- end: jest.fn(),
13
- spanContext: jest.fn(() => ({ traceId: 't', spanId: 's', traceFlags: 1 }))
14
- });
9
+ // A partial span: only the members these tests exercise are stubbed, cast to
10
+ // the full Span type so it satisfies the now-typed tracing helpers. The jest
11
+ // mocks remain reachable at runtime for the assertions below.
12
+ const mockSpan = () =>
13
+ ({
14
+ setAttribute: jest.fn(),
15
+ addEvent: jest.fn(),
16
+ end: jest.fn(),
17
+ spanContext: jest.fn(() => ({ traceId: 't', spanId: 's', traceFlags: 1 }))
18
+ }) as unknown as Span;
15
19
 
16
20
  const mockTracer = { startSpan: jest.fn(() => mockSpan()) };
17
21
  const registerMock = jest.fn();
@@ -38,7 +42,7 @@ jest.mock('@opentelemetry/resources', () => ({
38
42
  resourceFromAttributes: resourceFromAttributesMock
39
43
  }));
40
44
 
41
- import { context } from '@opentelemetry/api';
45
+ import { context, Span } from '@opentelemetry/api';
42
46
  import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
43
47
 
44
48
  async function loadTracing() {