@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.
- package/README.md +52 -0
- package/dist/app/shutdown.d.ts +31 -0
- package/dist/app/shutdown.d.ts.map +1 -0
- package/dist/app/shutdown.js +58 -0
- package/dist/app/shutdown.js.map +1 -0
- package/dist/health/createHealthCheck.d.ts +32 -0
- package/dist/health/createHealthCheck.d.ts.map +1 -0
- package/dist/health/createHealthCheck.js +50 -0
- package/dist/health/createHealthCheck.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/logger.js +1 -1
- package/dist/logging/logger.js.map +1 -1
- package/dist/metrics/index.d.ts +3 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/middleware/requestLogger.d.ts +3 -2
- package/dist/middleware/requestLogger.d.ts.map +1 -1
- package/dist/middleware/requestLogger.js.map +1 -1
- package/dist/tracing/index.d.ts +9 -8
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +7 -7
- package/dist/tracing/index.js.map +1 -1
- package/dist/types/auth.d.ts +9 -1
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/utils/correlation.js.map +1 -1
- package/dist/utils/permissionUtils.d.ts +31 -0
- package/dist/utils/permissionUtils.d.ts.map +1 -0
- package/dist/utils/permissionUtils.js +53 -0
- package/dist/utils/permissionUtils.js.map +1 -0
- package/dist/utils/response.d.ts +3 -3
- package/dist/utils/response.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/app/shutdown.test.ts +129 -0
- package/src/app/shutdown.ts +81 -0
- package/src/health/createHealthCheck.test.ts +89 -0
- package/src/health/createHealthCheck.ts +67 -0
- package/src/index.ts +5 -0
- package/src/logging/logger.ts +1 -1
- package/src/metrics/index.ts +3 -2
- package/src/middleware/errorHandler.ts +2 -2
- package/src/middleware/requestLogger.ts +2 -1
- package/src/tracing/index.test.ts +11 -7
- package/src/tracing/index.ts +26 -13
- package/src/types/auth.ts +10 -1
- package/src/utils/correlation.ts +1 -1
- package/src/utils/permissionUtils.test.ts +47 -0
- package/src/utils/permissionUtils.ts +68 -0
- 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"}
|
package/dist/utils/response.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Response } from 'express';
|
|
|
2
2
|
/**
|
|
3
3
|
* Standard API response format
|
|
4
4
|
*/
|
|
5
|
-
export interface ApiResponse<T =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
package/src/logging/logger.ts
CHANGED
|
@@ -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
|
|
36
|
+
(info) => `${info.timestamp} ${info.level}: ${String(info.message)}`,
|
|
37
37
|
),
|
|
38
38
|
);
|
|
39
39
|
|
package/src/metrics/index.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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() {
|