@tumbaland/backend-core 1.44.0 → 1.46.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/dist/fileService/client.d.ts +54 -0
- package/dist/fileService/client.d.ts.map +1 -0
- package/dist/fileService/client.js +137 -0
- package/dist/fileService/client.js.map +1 -0
- package/dist/fileService/index.d.ts +4 -0
- package/dist/fileService/index.d.ts.map +1 -0
- package/dist/fileService/index.js +40 -0
- package/dist/fileService/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +4 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +5 -8
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/utils/escapeRegex.d.ts +8 -0
- package/dist/utils/escapeRegex.d.ts.map +1 -0
- package/dist/utils/escapeRegex.js +12 -0
- package/dist/utils/escapeRegex.js.map +1 -0
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* logger.ts builds its transport list at module-load time based on
|
|
5
|
-
* NODE_ENV/LOG_TO_FILE, so each scenario needs a fresh module load via
|
|
6
|
-
* isolateModulesAsync rather than a single shared import.
|
|
7
|
-
*/
|
|
8
|
-
async function loadLogger(env: Record<string, string | undefined>) {
|
|
9
|
-
const ORIGINAL_ENV = process.env;
|
|
10
|
-
process.env = { ...ORIGINAL_ENV, ...env };
|
|
11
|
-
|
|
12
|
-
let loggerModule!: typeof import('./logger').default;
|
|
13
|
-
await jest.isolateModulesAsync(async () => {
|
|
14
|
-
loggerModule = (await import('./logger')).default;
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
process.env = ORIGINAL_ENV;
|
|
18
|
-
return loggerModule;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// winston's `.transports` also holds the wrapped exception/rejection-handler
|
|
22
|
-
// transports (always present), so assert on named regular transports only.
|
|
23
|
-
function namedTransports(logger: import('winston').Logger, name: string) {
|
|
24
|
-
return logger.transports.filter((t) => (t as unknown as { name?: string }).name === name);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('logger', () => {
|
|
28
|
-
it('logs at debug level in development', async () => {
|
|
29
|
-
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
|
|
30
|
-
expect(logger.level).toBe('debug');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('logs at info level in production', async () => {
|
|
34
|
-
const logger = await loadLogger({ NODE_ENV: 'production' });
|
|
35
|
-
expect(logger.level).toBe('info');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('defaults to development (debug level) when NODE_ENV is unset', async () => {
|
|
39
|
-
const ORIGINAL_ENV = process.env;
|
|
40
|
-
const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
|
|
41
|
-
delete merged.NODE_ENV;
|
|
42
|
-
process.env = merged as NodeJS.ProcessEnv;
|
|
43
|
-
|
|
44
|
-
let loggerModule!: typeof import('./logger').default;
|
|
45
|
-
await jest.isolateModulesAsync(async () => {
|
|
46
|
-
loggerModule = (await import('./logger')).default;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
process.env = ORIGINAL_ENV;
|
|
50
|
-
expect(loggerModule.level).toBe('debug');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('does not create a file transport when LOG_TO_FILE is unset', async () => {
|
|
54
|
-
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: undefined });
|
|
55
|
-
expect(namedTransports(logger, 'file')).toHaveLength(0);
|
|
56
|
-
expect(namedTransports(logger, 'console')).toHaveLength(1);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('does not create a file transport in production, even if LOG_TO_FILE is true', async () => {
|
|
60
|
-
const logger = await loadLogger({ NODE_ENV: 'production', LOG_TO_FILE: 'true' });
|
|
61
|
-
expect(namedTransports(logger, 'file')).toHaveLength(0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('with LOG_TO_FILE=true in development', () => {
|
|
65
|
-
it('creates the logs directory when it does not exist and adds a file transport', async () => {
|
|
66
|
-
const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
67
|
-
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
|
|
68
|
-
|
|
69
|
-
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
|
|
70
|
-
|
|
71
|
-
expect(mkdirSpy).toHaveBeenCalledWith(expect.stringContaining('logs'), { recursive: true });
|
|
72
|
-
expect(namedTransports(logger, 'file')).toHaveLength(1);
|
|
73
|
-
|
|
74
|
-
existsSpy.mockRestore();
|
|
75
|
-
mkdirSpy.mockRestore();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('does not recreate the logs directory when it already exists', async () => {
|
|
79
|
-
const existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
80
|
-
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
|
|
81
|
-
|
|
82
|
-
const logger = await loadLogger({ NODE_ENV: 'development', LOG_TO_FILE: 'true' });
|
|
83
|
-
|
|
84
|
-
expect(mkdirSpy).not.toHaveBeenCalled();
|
|
85
|
-
expect(namedTransports(logger, 'file')).toHaveLength(1);
|
|
86
|
-
|
|
87
|
-
existsSpy.mockRestore();
|
|
88
|
-
mkdirSpy.mockRestore();
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
});
|
package/src/logging/logger.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import winston from 'winston';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
|
|
5
|
-
// Define log levels
|
|
6
|
-
const levels = {
|
|
7
|
-
error: 0,
|
|
8
|
-
warn: 1,
|
|
9
|
-
info: 2,
|
|
10
|
-
http: 3,
|
|
11
|
-
debug: 4,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const level = () => {
|
|
15
|
-
const env = process.env.NODE_ENV || 'development';
|
|
16
|
-
const isDevelopment = env === 'development';
|
|
17
|
-
return isDevelopment ? 'debug' : 'info';
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// Define colors for each level
|
|
21
|
-
const colors = {
|
|
22
|
-
error: 'red',
|
|
23
|
-
warn: 'yellow',
|
|
24
|
-
info: 'green',
|
|
25
|
-
http: 'magenta',
|
|
26
|
-
debug: 'white',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
winston.addColors(colors);
|
|
30
|
-
|
|
31
|
-
// Define format for console (human-readable)
|
|
32
|
-
const consoleFormat = winston.format.combine(
|
|
33
|
-
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
|
34
|
-
winston.format.colorize({ all: true }),
|
|
35
|
-
winston.format.printf(
|
|
36
|
-
(info) => `${info.timestamp} ${info.level}: ${String(info.message)}`,
|
|
37
|
-
),
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
// Define JSON format for production (structured logging)
|
|
41
|
-
const jsonFormat = winston.format.combine(
|
|
42
|
-
winston.format.timestamp(),
|
|
43
|
-
winston.format.errors({ stack: true }),
|
|
44
|
-
winston.format.json()
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
// Choose format based on environment
|
|
48
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
49
|
-
const logFormat = isProduction ? jsonFormat : consoleFormat;
|
|
50
|
-
|
|
51
|
-
// Define which transports the logger must use
|
|
52
|
-
const transports: winston.transport[] = [
|
|
53
|
-
// Console transport (goes to Docker stdout/stderr)
|
|
54
|
-
new winston.transports.Console({
|
|
55
|
-
format: logFormat,
|
|
56
|
-
level: level()
|
|
57
|
-
}),
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
// Add file transport only in development (optional)
|
|
61
|
-
if (!isProduction && process.env.LOG_TO_FILE === 'true') {
|
|
62
|
-
|
|
63
|
-
// Create logs directory if it doesn't exist
|
|
64
|
-
const logsDir = path.join(process.cwd(), 'logs');
|
|
65
|
-
if (!fs.existsSync(logsDir)) {
|
|
66
|
-
fs.mkdirSync(logsDir, { recursive: true });
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
transports.push(
|
|
70
|
-
new winston.transports.File({
|
|
71
|
-
filename: path.join(logsDir, 'app.log'),
|
|
72
|
-
format: jsonFormat,
|
|
73
|
-
level: 'debug'
|
|
74
|
-
})
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Create the logger instance
|
|
79
|
-
const logger = winston.createLogger({
|
|
80
|
-
level: level(),
|
|
81
|
-
levels,
|
|
82
|
-
format: logFormat,
|
|
83
|
-
transports,
|
|
84
|
-
// Handle exceptions and rejections
|
|
85
|
-
exceptionHandlers: [
|
|
86
|
-
new winston.transports.Console({
|
|
87
|
-
format: winston.format.combine(
|
|
88
|
-
winston.format.colorize(),
|
|
89
|
-
winston.format.simple()
|
|
90
|
-
)
|
|
91
|
-
})
|
|
92
|
-
],
|
|
93
|
-
rejectionHandlers: [
|
|
94
|
-
new winston.transports.Console({
|
|
95
|
-
format: winston.format.combine(
|
|
96
|
-
winston.format.colorize(),
|
|
97
|
-
winston.format.simple()
|
|
98
|
-
)
|
|
99
|
-
})
|
|
100
|
-
]
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
export default logger;
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import {
|
|
3
|
-
activeConnections,
|
|
4
|
-
businessMetrics,
|
|
5
|
-
httpRequestDuration,
|
|
6
|
-
httpRequestsTotal,
|
|
7
|
-
metricsHandler,
|
|
8
|
-
metricsMiddleware,
|
|
9
|
-
register
|
|
10
|
-
} from './index';
|
|
11
|
-
|
|
12
|
-
function mockRes(): Response {
|
|
13
|
-
const res: Partial<Response> & { _finishHandler?: () => void } = {};
|
|
14
|
-
res.set = jest.fn().mockReturnValue(res);
|
|
15
|
-
res.end = jest.fn().mockReturnValue(res);
|
|
16
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
17
|
-
res.statusCode = 200;
|
|
18
|
-
res.on = jest.fn((event: string, handler: () => void) => {
|
|
19
|
-
if (event === 'finish') res._finishHandler = handler;
|
|
20
|
-
return res as Response;
|
|
21
|
-
});
|
|
22
|
-
return res as Response;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('metricsHandler', () => {
|
|
26
|
-
it('writes the registry content-type header and the serialized metrics body', async () => {
|
|
27
|
-
const res = mockRes();
|
|
28
|
-
|
|
29
|
-
await metricsHandler({} as Request, res);
|
|
30
|
-
|
|
31
|
-
expect(res.set).toHaveBeenCalledWith('Content-Type', register.contentType);
|
|
32
|
-
expect(res.end).toHaveBeenCalledWith(expect.any(String));
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('responds 500 when the registry fails to serialize metrics', async () => {
|
|
36
|
-
const res = mockRes();
|
|
37
|
-
jest.spyOn(register, 'metrics').mockRejectedValueOnce(new Error('boom'));
|
|
38
|
-
|
|
39
|
-
await metricsHandler({} as Request, res);
|
|
40
|
-
|
|
41
|
-
expect(res.status).toHaveBeenCalledWith(500);
|
|
42
|
-
expect(res.end).toHaveBeenCalledWith('Error generating metrics');
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('metricsMiddleware', () => {
|
|
47
|
-
it('increments active connections immediately and decrements once the response finishes', async () => {
|
|
48
|
-
const req = { method: 'GET', url: '/albums' } as Request;
|
|
49
|
-
const res = mockRes() as Response & { _finishHandler?: () => void };
|
|
50
|
-
const next = jest.fn();
|
|
51
|
-
const before = (await activeConnections.get()).values[0]?.value ?? 0;
|
|
52
|
-
|
|
53
|
-
metricsMiddleware(req, res, next);
|
|
54
|
-
|
|
55
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
56
|
-
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
|
57
|
-
expect((await activeConnections.get()).values[0]?.value).toBe(before + 1);
|
|
58
|
-
|
|
59
|
-
(res as any)._finishHandler();
|
|
60
|
-
|
|
61
|
-
expect((await activeConnections.get()).values[0]?.value).toBe(before);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('records request duration and total-count metrics labeled by method/url/status on finish', () => {
|
|
65
|
-
const req = { method: 'POST', url: '/albums' } as Request;
|
|
66
|
-
const res = mockRes();
|
|
67
|
-
res.statusCode = 201;
|
|
68
|
-
const next = jest.fn();
|
|
69
|
-
|
|
70
|
-
const durationSpy = jest.spyOn(httpRequestDuration, 'labels');
|
|
71
|
-
const totalSpy = jest.spyOn(httpRequestsTotal, 'labels');
|
|
72
|
-
|
|
73
|
-
metricsMiddleware(req, res, next);
|
|
74
|
-
(res as any)._finishHandler();
|
|
75
|
-
|
|
76
|
-
expect(durationSpy).toHaveBeenCalledWith('POST', '/albums', '201');
|
|
77
|
-
expect(totalSpy).toHaveBeenCalledWith('POST', '/albums', '201');
|
|
78
|
-
|
|
79
|
-
durationSpy.mockRestore();
|
|
80
|
-
totalSpy.mockRestore();
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('default labels', () => {
|
|
85
|
-
it('falls back to app "unknown-service" and version "1.0.0" when their env vars are unset', async () => {
|
|
86
|
-
const ORIGINAL_ENV = process.env;
|
|
87
|
-
const merged: Record<string, string | undefined> = { ...ORIGINAL_ENV };
|
|
88
|
-
delete merged.SERVICE_NAME;
|
|
89
|
-
delete merged.npm_package_version;
|
|
90
|
-
process.env = merged as NodeJS.ProcessEnv;
|
|
91
|
-
|
|
92
|
-
// isolateModulesAsync sandboxes the whole module registry, including
|
|
93
|
-
// prom-client itself — the spy must target the Registry class required
|
|
94
|
-
// *inside* the sandbox, not the one imported at the top of this file.
|
|
95
|
-
await jest.isolateModulesAsync(async () => {
|
|
96
|
-
const innerPromClient = (await import('prom-client')).default;
|
|
97
|
-
const setDefaultLabelsSpy = jest.spyOn(innerPromClient.Registry.prototype, 'setDefaultLabels');
|
|
98
|
-
|
|
99
|
-
await import('./index');
|
|
100
|
-
|
|
101
|
-
expect(setDefaultLabelsSpy).toHaveBeenCalledWith({ app: 'unknown-service', version: '1.0.0' });
|
|
102
|
-
setDefaultLabelsSpy.mockRestore();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
process.env = ORIGINAL_ENV;
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('businessMetrics', () => {
|
|
110
|
-
it('exposes counters that can be incremented without throwing', () => {
|
|
111
|
-
expect(() => businessMetrics.albumsCreated.inc()).not.toThrow();
|
|
112
|
-
expect(() => businessMetrics.filesUploaded.inc({ file_type: 'image' })).not.toThrow();
|
|
113
|
-
expect(() => businessMetrics.usersRegistered.inc()).not.toThrow();
|
|
114
|
-
expect(() => businessMetrics.paymentsProcessed.inc({ status: 'success', method: 'card' })).not.toThrow();
|
|
115
|
-
});
|
|
116
|
-
});
|
package/src/metrics/index.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import promClient from 'prom-client';
|
|
2
|
-
import { Request, Response, NextFunction } from 'express';
|
|
3
|
-
|
|
4
|
-
// Create a Registry which registers the metrics
|
|
5
|
-
const register = new promClient.Registry();
|
|
6
|
-
|
|
7
|
-
// Add a default label which is added to all metrics
|
|
8
|
-
register.setDefaultLabels({
|
|
9
|
-
app: process.env.SERVICE_NAME || 'unknown-service',
|
|
10
|
-
version: process.env.npm_package_version || '1.0.0'
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
// Enable the collection of default metrics
|
|
14
|
-
promClient.collectDefaultMetrics({ register });
|
|
15
|
-
|
|
16
|
-
// Custom metrics
|
|
17
|
-
export const httpRequestDuration = new promClient.Histogram({
|
|
18
|
-
name: 'http_request_duration_seconds',
|
|
19
|
-
help: 'Duration of HTTP requests in seconds',
|
|
20
|
-
labelNames: ['method', 'route', 'status_code'],
|
|
21
|
-
buckets: [0.1, 0.5, 1, 2, 5, 10]
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export const httpRequestsTotal = new promClient.Counter({
|
|
25
|
-
name: 'http_requests_total',
|
|
26
|
-
help: 'Total number of HTTP requests',
|
|
27
|
-
labelNames: ['method', 'route', 'status_code']
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const activeConnections = new promClient.Gauge({
|
|
31
|
-
name: 'active_connections',
|
|
32
|
-
help: 'Number of active connections'
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
export const databaseQueryDuration = new promClient.Histogram({
|
|
36
|
-
name: 'database_query_duration_seconds',
|
|
37
|
-
help: 'Duration of database queries in seconds',
|
|
38
|
-
labelNames: ['operation', 'collection'],
|
|
39
|
-
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
export const databaseQueriesTotal = new promClient.Counter({
|
|
43
|
-
name: 'database_queries_total',
|
|
44
|
-
help: 'Total number of database queries',
|
|
45
|
-
labelNames: ['operation', 'collection', 'status']
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
export const businessMetrics = {
|
|
49
|
-
albumsCreated: new promClient.Counter({
|
|
50
|
-
name: 'albums_created_total',
|
|
51
|
-
help: 'Total number of albums created'
|
|
52
|
-
}),
|
|
53
|
-
|
|
54
|
-
filesUploaded: new promClient.Counter({
|
|
55
|
-
name: 'files_uploaded_total',
|
|
56
|
-
help: 'Total number of files uploaded',
|
|
57
|
-
labelNames: ['file_type']
|
|
58
|
-
}),
|
|
59
|
-
|
|
60
|
-
usersRegistered: new promClient.Counter({
|
|
61
|
-
name: 'users_registered_total',
|
|
62
|
-
help: 'Total number of users registered'
|
|
63
|
-
}),
|
|
64
|
-
|
|
65
|
-
paymentsProcessed: new promClient.Counter({
|
|
66
|
-
name: 'payments_processed_total',
|
|
67
|
-
help: 'Total number of payments processed',
|
|
68
|
-
labelNames: ['status', 'method']
|
|
69
|
-
})
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// Metrics endpoint handler
|
|
73
|
-
export const metricsHandler = async (_req: Request, res: Response) => {
|
|
74
|
-
try {
|
|
75
|
-
res.set('Content-Type', register.contentType);
|
|
76
|
-
const metrics = await register.metrics();
|
|
77
|
-
res.end(metrics);
|
|
78
|
-
} catch (error) {
|
|
79
|
-
res.status(500).end('Error generating metrics');
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// Middleware to collect HTTP metrics
|
|
84
|
-
export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
|
85
|
-
const start = Date.now();
|
|
86
|
-
const { method, url } = req;
|
|
87
|
-
|
|
88
|
-
// Increment active connections
|
|
89
|
-
activeConnections.inc();
|
|
90
|
-
|
|
91
|
-
res.on('finish', () => {
|
|
92
|
-
const duration = (Date.now() - start) / 1000; // Convert to seconds
|
|
93
|
-
const { statusCode } = res;
|
|
94
|
-
|
|
95
|
-
// Record metrics
|
|
96
|
-
httpRequestDuration
|
|
97
|
-
.labels(method, url, statusCode.toString())
|
|
98
|
-
.observe(duration);
|
|
99
|
-
|
|
100
|
-
httpRequestsTotal
|
|
101
|
-
.labels(method, url, statusCode.toString())
|
|
102
|
-
.inc();
|
|
103
|
-
|
|
104
|
-
// Decrement active connections
|
|
105
|
-
activeConnections.dec();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
next();
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
export { register };
|
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
import jwt from 'jsonwebtoken';
|
|
3
|
-
import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
4
|
-
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
5
|
-
import { mintAccessToken } from '../oauth/tokens';
|
|
6
|
-
import { UserPayload } from '../types/auth';
|
|
7
|
-
import { overrideSessionCheckForTests } from '../auth/session';
|
|
8
|
-
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
9
|
-
|
|
10
|
-
// These tokens carry no session; the session check has its own tests below and in session.test.ts.
|
|
11
|
-
beforeEach(() => overrideSessionCheckForTests(async () => true));
|
|
12
|
-
afterAll(() => overrideSessionCheckForTests());
|
|
13
|
-
|
|
14
|
-
function mockReq(overrides: Partial<Request> = {}): Request {
|
|
15
|
-
return { cookies: {}, headers: {}, ...overrides } as unknown as Request;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function mockRes(): Response {
|
|
19
|
-
const res: Partial<Response> = {};
|
|
20
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
21
|
-
res.json = jest.fn().mockReturnValue(res);
|
|
22
|
-
return res as Response;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('authenticateToken', () => {
|
|
26
|
-
const ORIGINAL_ENV = process.env;
|
|
27
|
-
const next = jest.fn();
|
|
28
|
-
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
31
|
-
next.mockClear();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterAll(() => {
|
|
35
|
-
process.env = ORIGINAL_ENV;
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
|
|
39
|
-
|
|
40
|
-
it('attaches the decoded user and calls next for a valid cookie token', async () => {
|
|
41
|
-
const token = jwt.sign(payload, 'test-secret');
|
|
42
|
-
const req = mockReq({ cookies: { access_token: token } });
|
|
43
|
-
const res = mockRes();
|
|
44
|
-
|
|
45
|
-
await authenticateToken(req, res, next);
|
|
46
|
-
|
|
47
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
48
|
-
expect(req.user).toMatchObject({ id: '1', email: 'user@example.com' });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('attaches an empty userGroups array when the token has no groups claim', async () => {
|
|
52
|
-
const token = jwt.sign(payload, 'test-secret');
|
|
53
|
-
const req = mockReq({ cookies: { access_token: token } });
|
|
54
|
-
const res = mockRes();
|
|
55
|
-
|
|
56
|
-
await authenticateToken(req, res, next);
|
|
57
|
-
|
|
58
|
-
expect(req.userGroups).toEqual([]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("takes the person's groups from group-service, not from the token", async () => {
|
|
62
|
-
const lookup = jest.fn(async () => ['live-group']);
|
|
63
|
-
overrideMembershipLookupForTests(lookup);
|
|
64
|
-
const token = jwt.sign({ ...payload, groups: ['stale-group'] }, 'test-secret');
|
|
65
|
-
const req = mockReq({ cookies: { access_token: token } });
|
|
66
|
-
const res = mockRes();
|
|
67
|
-
|
|
68
|
-
await authenticateToken(req, res, next);
|
|
69
|
-
|
|
70
|
-
expect(lookup).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
|
|
71
|
-
expect(req.userGroups).toEqual(['live-group']);
|
|
72
|
-
expect(req.user?.groups).toEqual(['live-group']);
|
|
73
|
-
overrideMembershipLookupForTests();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('accepts a Bearer token from the Authorization header', async () => {
|
|
77
|
-
const token = jwt.sign(payload, 'test-secret');
|
|
78
|
-
const req = mockReq({ headers: { authorization: `Bearer ${token}` } });
|
|
79
|
-
const res = mockRes();
|
|
80
|
-
|
|
81
|
-
await authenticateToken(req, res, next);
|
|
82
|
-
|
|
83
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('returns 401 when no token is present', async () => {
|
|
87
|
-
const req = mockReq();
|
|
88
|
-
const res = mockRes();
|
|
89
|
-
|
|
90
|
-
await authenticateToken(req, res, next);
|
|
91
|
-
|
|
92
|
-
expect(next).not.toHaveBeenCalled();
|
|
93
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
94
|
-
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Access token required' });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('returns 401 for an invalid token', async () => {
|
|
98
|
-
const req = mockReq({ cookies: { access_token: 'not-a-real-token' } });
|
|
99
|
-
const res = mockRes();
|
|
100
|
-
|
|
101
|
-
await authenticateToken(req, res, next);
|
|
102
|
-
|
|
103
|
-
expect(next).not.toHaveBeenCalled();
|
|
104
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
105
|
-
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Invalid or expired token' });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('returns 401 for a token signed with a different secret', async () => {
|
|
109
|
-
const token = jwt.sign(payload, 'wrong-secret');
|
|
110
|
-
const req = mockReq({ cookies: { access_token: token } });
|
|
111
|
-
const res = mockRes();
|
|
112
|
-
|
|
113
|
-
await authenticateToken(req, res, next);
|
|
114
|
-
|
|
115
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe('optionalAuth', () => {
|
|
120
|
-
const ORIGINAL_ENV = process.env;
|
|
121
|
-
const next = jest.fn();
|
|
122
|
-
|
|
123
|
-
beforeEach(() => {
|
|
124
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
125
|
-
next.mockClear();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
afterAll(() => {
|
|
129
|
-
process.env = ORIGINAL_ENV;
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
|
|
133
|
-
|
|
134
|
-
it('attaches the user when the token is valid', async () => {
|
|
135
|
-
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
136
|
-
|
|
137
|
-
await optionalAuth(req, mockRes(), next);
|
|
138
|
-
|
|
139
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
140
|
-
expect(req.user).toMatchObject({ email: 'user@example.com' });
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('reads a bearer token too', async () => {
|
|
144
|
-
const req = mockReq({ headers: { authorization: `Bearer ${jwt.sign(payload, 'test-secret')}` } });
|
|
145
|
-
|
|
146
|
-
await optionalAuth(req, mockRes(), next);
|
|
147
|
-
|
|
148
|
-
expect(req.user).toMatchObject({ email: 'user@example.com' });
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('continues anonymously when there is no token', async () => {
|
|
152
|
-
const req = mockReq();
|
|
153
|
-
const res = mockRes();
|
|
154
|
-
|
|
155
|
-
await optionalAuth(req, res, next);
|
|
156
|
-
|
|
157
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
158
|
-
expect(req.user).toBeUndefined();
|
|
159
|
-
expect(res.status).not.toHaveBeenCalled();
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('treats an expired or forged token as no token rather than as a 401', async () => {
|
|
163
|
-
// The caller asked for public data. Refusing it because a stale cookie is
|
|
164
|
-
// lying around would break the landing page for exactly the people most
|
|
165
|
-
// likely to have one.
|
|
166
|
-
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'wrong-secret') } });
|
|
167
|
-
const res = mockRes();
|
|
168
|
-
|
|
169
|
-
await optionalAuth(req, res, next);
|
|
170
|
-
|
|
171
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
172
|
-
expect(req.user).toBeUndefined();
|
|
173
|
-
expect(res.status).not.toHaveBeenCalled();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('continues anonymously when JWT_SECRET is not configured', async () => {
|
|
177
|
-
delete process.env.JWT_SECRET;
|
|
178
|
-
const req = mockReq({ cookies: { access_token: 'anything' } });
|
|
179
|
-
const res = mockRes();
|
|
180
|
-
|
|
181
|
-
await optionalAuth(req, res, next);
|
|
182
|
-
|
|
183
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
184
|
-
expect(res.status).not.toHaveBeenCalled();
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('OAuth access tokens are not sessions', () => {
|
|
189
|
-
const ORIGINAL_ENV = process.env;
|
|
190
|
-
|
|
191
|
-
beforeEach(() => {
|
|
192
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
afterEach(() => {
|
|
196
|
-
process.env = ORIGINAL_ENV;
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const accessToken = () =>
|
|
200
|
-
mintAccessToken({
|
|
201
|
-
userId: 'u1',
|
|
202
|
-
email: 'u1@example.com',
|
|
203
|
-
name: 'Tester',
|
|
204
|
-
resource: 'https://mcp.example.com',
|
|
205
|
-
issuer: 'https://auth.example.com',
|
|
206
|
-
scopes: ['relationship:read'],
|
|
207
|
-
tenants: [PERSONAL_TENANT],
|
|
208
|
-
defaultTenant: PERSONAL_TENANT,
|
|
209
|
-
tenantNames: {},
|
|
210
|
-
clientId: 'client-1',
|
|
211
|
-
clientName: 'Claude'
|
|
212
|
-
}).accessToken;
|
|
213
|
-
|
|
214
|
-
it('refuses one on a session-only route', async () => {
|
|
215
|
-
const res = mockRes();
|
|
216
|
-
const next = jest.fn();
|
|
217
|
-
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
218
|
-
|
|
219
|
-
// It verifies — same secret — so nothing but the type check stands between
|
|
220
|
-
// an assistant and every route that has no scopes to be held to.
|
|
221
|
-
await authenticateToken(req, res, next);
|
|
222
|
-
|
|
223
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
224
|
-
expect(next).not.toHaveBeenCalled();
|
|
225
|
-
expect(req.user).toBeUndefined();
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('treats one as no session at all on an optional-auth route', async () => {
|
|
229
|
-
const next = jest.fn();
|
|
230
|
-
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
231
|
-
|
|
232
|
-
await optionalAuth(req, mockRes(), next);
|
|
233
|
-
|
|
234
|
-
// The route still answers, in its public form — the same as a stale cookie.
|
|
235
|
-
expect(next).toHaveBeenCalled();
|
|
236
|
-
expect(req.user).toBeUndefined();
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe('ended sessions', () => {
|
|
241
|
-
const ORIGINAL_ENV = process.env;
|
|
242
|
-
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User', sid: 'a'.repeat(24) };
|
|
243
|
-
|
|
244
|
-
beforeEach(() => {
|
|
245
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
246
|
-
overrideSessionCheckForTests(async () => false);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
afterAll(() => {
|
|
250
|
-
process.env = ORIGINAL_ENV;
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('authenticateToken refuses a validly signed token whose session has ended', async () => {
|
|
254
|
-
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
255
|
-
const res = mockRes();
|
|
256
|
-
const next = jest.fn();
|
|
257
|
-
|
|
258
|
-
await authenticateToken(req, res, next);
|
|
259
|
-
|
|
260
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
261
|
-
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Session has ended' });
|
|
262
|
-
expect(next).not.toHaveBeenCalled();
|
|
263
|
-
expect(req.user).toBeUndefined();
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('optionalAuth treats an ended session as no session', async () => {
|
|
267
|
-
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
268
|
-
const next = jest.fn();
|
|
269
|
-
|
|
270
|
-
await optionalAuth(req, mockRes(), next);
|
|
271
|
-
|
|
272
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
273
|
-
expect(req.user).toBeUndefined();
|
|
274
|
-
});
|
|
275
|
-
});
|