@tumbaland/backend-core 1.44.0 → 1.45.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/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,173 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
|
|
3
|
-
jest.mock('../logging/logger', () => ({
|
|
4
|
-
__esModule: true,
|
|
5
|
-
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
allowUserOrInternalService,
|
|
10
|
-
internalServiceTokenBypass,
|
|
11
|
-
requireInternalServiceToken,
|
|
12
|
-
verifyInternalToken
|
|
13
|
-
} from './internalServiceAuth';
|
|
14
|
-
|
|
15
|
-
function mockRes(): Response {
|
|
16
|
-
const res: Partial<Response> = {};
|
|
17
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
18
|
-
res.json = jest.fn().mockReturnValue(res);
|
|
19
|
-
return res as Response;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function mockReq(headers: Record<string, string> = {}): Request {
|
|
23
|
-
return { headers, originalUrl: '/internal/entitlements' } as unknown as Request;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const ORIGINAL_ENV = process.env;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
process.env = { ...ORIGINAL_ENV, INTERNAL_SERVICE_TOKEN: 'test-internal-token' };
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterAll(() => {
|
|
33
|
-
process.env = ORIGINAL_ENV;
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('verifyInternalToken', () => {
|
|
37
|
-
it('accepts only an exact match', () => {
|
|
38
|
-
expect(verifyInternalToken('test-internal-token')).toBe(true);
|
|
39
|
-
expect(verifyInternalToken('test-internal-tokeN')).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('rejects a prefix or an extension without throwing on the length mismatch', () => {
|
|
43
|
-
expect(verifyInternalToken('test')).toBe(false);
|
|
44
|
-
expect(verifyInternalToken('test-internal-token-and-more')).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('rejects anything that is not a non-empty string', () => {
|
|
48
|
-
expect(verifyInternalToken(undefined)).toBe(false);
|
|
49
|
-
expect(verifyInternalToken('')).toBe(false);
|
|
50
|
-
expect(verifyInternalToken(['test-internal-token'])).toBe(false);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('rejects everything when no token is configured', () => {
|
|
54
|
-
delete process.env.INTERNAL_SERVICE_TOKEN;
|
|
55
|
-
|
|
56
|
-
expect(verifyInternalToken('test-internal-token')).toBe(false);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('requireInternalServiceToken', () => {
|
|
61
|
-
it('marks the request internal and continues on a valid token', () => {
|
|
62
|
-
const req = mockReq({ 'x-internal-token': 'test-internal-token', 'x-service-id': 'album-service' });
|
|
63
|
-
const next = jest.fn();
|
|
64
|
-
|
|
65
|
-
requireInternalServiceToken(req, mockRes(), next);
|
|
66
|
-
|
|
67
|
-
expect(req.isInternalService).toBe(true);
|
|
68
|
-
expect(req.serviceIdentifier).toBe('album-service');
|
|
69
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('defaults the service identifier when the header is absent', () => {
|
|
73
|
-
const req = mockReq({ 'x-internal-token': 'test-internal-token' });
|
|
74
|
-
|
|
75
|
-
requireInternalServiceToken(req, mockRes(), jest.fn());
|
|
76
|
-
|
|
77
|
-
expect(req.serviceIdentifier).toBe('unknown');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('rejects a missing token with 401', () => {
|
|
81
|
-
const res = mockRes();
|
|
82
|
-
const next = jest.fn();
|
|
83
|
-
|
|
84
|
-
requireInternalServiceToken(mockReq(), res, next);
|
|
85
|
-
|
|
86
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
87
|
-
expect(next).not.toHaveBeenCalled();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('rejects a wrong token with 401', () => {
|
|
91
|
-
const res = mockRes();
|
|
92
|
-
const next = jest.fn();
|
|
93
|
-
|
|
94
|
-
requireInternalServiceToken(mockReq({ 'x-internal-token': 'wrong' }), res, next);
|
|
95
|
-
|
|
96
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
97
|
-
expect(next).not.toHaveBeenCalled();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('fails with 500 rather than open when the deployment has no token configured', () => {
|
|
101
|
-
delete process.env.INTERNAL_SERVICE_TOKEN;
|
|
102
|
-
const res = mockRes();
|
|
103
|
-
const next = jest.fn();
|
|
104
|
-
|
|
105
|
-
requireInternalServiceToken(mockReq({ 'x-internal-token': 'anything' }), res, next);
|
|
106
|
-
|
|
107
|
-
expect(res.status).toHaveBeenCalledWith(500);
|
|
108
|
-
expect(next).not.toHaveBeenCalled();
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe('internalServiceTokenBypass', () => {
|
|
113
|
-
it('passes a request with no internal header through untouched, for normal user auth', () => {
|
|
114
|
-
const req = mockReq();
|
|
115
|
-
const next = jest.fn();
|
|
116
|
-
|
|
117
|
-
internalServiceTokenBypass(req, mockRes(), next);
|
|
118
|
-
|
|
119
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
120
|
-
expect(req.isInternalService).toBeUndefined();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('rejects a bogus token instead of letting it stand in for authentication', () => {
|
|
124
|
-
// The bug this replaces: the header's mere presence used to skip JWT
|
|
125
|
-
// verification, so `x-internal-token: anything` reached the controller
|
|
126
|
-
// unauthenticated on any route that did not mount a second middleware.
|
|
127
|
-
const res = mockRes();
|
|
128
|
-
const next = jest.fn();
|
|
129
|
-
|
|
130
|
-
internalServiceTokenBypass(mockReq({ 'x-internal-token': 'anything' }), res, next);
|
|
131
|
-
|
|
132
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
133
|
-
expect(next).not.toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('marks a valid internal call and continues', () => {
|
|
137
|
-
const req = mockReq({ 'x-internal-token': 'test-internal-token' });
|
|
138
|
-
const next = jest.fn();
|
|
139
|
-
|
|
140
|
-
internalServiceTokenBypass(req, mockRes(), next);
|
|
141
|
-
|
|
142
|
-
expect(req.isInternalService).toBe(true);
|
|
143
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('allowUserOrInternalService', () => {
|
|
148
|
-
it('allows an internal service', () => {
|
|
149
|
-
const next = jest.fn();
|
|
150
|
-
|
|
151
|
-
allowUserOrInternalService({ isInternalService: true } as Request, mockRes(), next);
|
|
152
|
-
|
|
153
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('allows an authenticated user', () => {
|
|
157
|
-
const next = jest.fn();
|
|
158
|
-
|
|
159
|
-
allowUserOrInternalService({ user: { email: 'u@example.com' } } as Request, mockRes(), next);
|
|
160
|
-
|
|
161
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('rejects anyone else with 401', () => {
|
|
165
|
-
const res = mockRes();
|
|
166
|
-
const next = jest.fn();
|
|
167
|
-
|
|
168
|
-
allowUserOrInternalService({} as Request, res, next);
|
|
169
|
-
|
|
170
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
171
|
-
expect(next).not.toHaveBeenCalled();
|
|
172
|
-
});
|
|
173
|
-
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { Request, RequestHandler } from 'express';
|
|
2
|
-
import { timingSafeEqual } from 'crypto';
|
|
3
|
-
import logger from '../logging/logger';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Fields set by the internal-service middlewares below. Declared globally so
|
|
7
|
-
* routers and controllers read `req.isInternalService` off the same `Request`
|
|
8
|
-
* type Express itself uses, with no cast at the call site.
|
|
9
|
-
*/
|
|
10
|
-
declare global {
|
|
11
|
-
namespace Express {
|
|
12
|
-
interface Request {
|
|
13
|
-
isInternalService?: boolean;
|
|
14
|
-
serviceIdentifier?: string;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const INTERNAL_TOKEN_HEADER = 'x-internal-token';
|
|
20
|
-
export const SERVICE_ID_HEADER = 'x-service-id';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Constant-time comparison of the presented token against the configured one.
|
|
24
|
-
*
|
|
25
|
-
* `===` on secrets leaks their prefix through timing, and `timingSafeEqual`
|
|
26
|
-
* throws on a length mismatch (which is itself a leak), so length is compared
|
|
27
|
-
* first and both buffers are hashed to a fixed width — a token of the wrong
|
|
28
|
-
* length takes the same path as one of the right length with wrong bytes.
|
|
29
|
-
*/
|
|
30
|
-
export function verifyInternalToken(presented: unknown): boolean {
|
|
31
|
-
const expected = process.env.INTERNAL_SERVICE_TOKEN;
|
|
32
|
-
if (!expected) return false;
|
|
33
|
-
if (typeof presented !== 'string' || presented.length === 0) return false;
|
|
34
|
-
|
|
35
|
-
const a = Buffer.from(presented, 'utf8');
|
|
36
|
-
const b = Buffer.from(expected, 'utf8');
|
|
37
|
-
if (a.length !== b.length) return false;
|
|
38
|
-
return timingSafeEqual(a, b);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function markInternal(req: Request): void {
|
|
42
|
-
req.isInternalService = true;
|
|
43
|
-
req.serviceIdentifier = (req.headers[SERVICE_ID_HEADER] as string) || 'unknown';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Hard gate for endpoints that are only ever called service-to-service.
|
|
48
|
-
* No user-JWT fallback: a missing or wrong token is always a 401.
|
|
49
|
-
*/
|
|
50
|
-
export const requireInternalServiceToken: RequestHandler = (req, res, next) => {
|
|
51
|
-
if (!process.env.INTERNAL_SERVICE_TOKEN) {
|
|
52
|
-
logger.error('INTERNAL_SERVICE_TOKEN not configured');
|
|
53
|
-
res.status(500).json({ success: false, message: 'Internal service authentication not configured' });
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!verifyInternalToken(req.headers[INTERNAL_TOKEN_HEADER])) {
|
|
58
|
-
logger.warn('Rejected internal service request', {
|
|
59
|
-
serviceId: (req.headers[SERVICE_ID_HEADER] as string) || 'unknown',
|
|
60
|
-
path: req.originalUrl
|
|
61
|
-
});
|
|
62
|
-
res.status(401).json({ success: false, message: 'Invalid or missing internal service token' });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
markInternal(req);
|
|
67
|
-
next();
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* For routers that serve both browsers and services: when the internal-token
|
|
72
|
-
* header is present it is *verified here*, and a bad token is rejected rather
|
|
73
|
-
* than passed along.
|
|
74
|
-
*
|
|
75
|
-
* The bug this replaces: treating the mere presence of the header as grounds to
|
|
76
|
-
* skip JWT verification, leaving the real check to a second middleware mounted
|
|
77
|
-
* per-router — so any route that forgot to mount the second one was reachable
|
|
78
|
-
* by sending `x-internal-token: anything`.
|
|
79
|
-
*/
|
|
80
|
-
export const internalServiceTokenBypass: RequestHandler = (req, res, next) => {
|
|
81
|
-
const presented = req.headers[INTERNAL_TOKEN_HEADER];
|
|
82
|
-
if (presented === undefined) {
|
|
83
|
-
next();
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
requireInternalServiceToken(req, res, next);
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
/** Allows a request that authenticated as either a user or an internal service. */
|
|
90
|
-
export const allowUserOrInternalService: RequestHandler = (req, res, next) => {
|
|
91
|
-
if (req.isInternalService || req.user) {
|
|
92
|
-
next();
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
96
|
-
};
|
|
@@ -1,81 +0,0 @@
|
|
|
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
|
-
import logger from '../logging/logger';
|
|
7
|
-
import { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './requestLogger';
|
|
8
|
-
|
|
9
|
-
function mockReqRes() {
|
|
10
|
-
const req: any = {
|
|
11
|
-
method: 'GET',
|
|
12
|
-
url: '/albums/123',
|
|
13
|
-
correlationId: 'corr-1',
|
|
14
|
-
ip: '127.0.0.1',
|
|
15
|
-
get: jest.fn().mockReturnValue('test-agent')
|
|
16
|
-
};
|
|
17
|
-
const res: any = { statusCode: 200, on: jest.fn() };
|
|
18
|
-
return { req, res };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe('simpleRequestLogger', () => {
|
|
22
|
-
beforeEach(() => jest.clearAllMocks());
|
|
23
|
-
|
|
24
|
-
it('logs an inbound request line with correlation id, ip, and user agent', () => {
|
|
25
|
-
const { req, res } = mockReqRes();
|
|
26
|
-
const next = jest.fn();
|
|
27
|
-
|
|
28
|
-
simpleRequestLogger(req, res, next);
|
|
29
|
-
|
|
30
|
-
expect(logger.info).toHaveBeenCalledWith('→ GET /albums/123', {
|
|
31
|
-
correlationId: 'corr-1',
|
|
32
|
-
ip: '127.0.0.1',
|
|
33
|
-
userAgent: 'test-agent'
|
|
34
|
-
});
|
|
35
|
-
expect(next).toHaveBeenCalledTimes(1);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('falls back to "unknown" correlation id when none was set on the request', () => {
|
|
39
|
-
const { req, res } = mockReqRes();
|
|
40
|
-
delete req.correlationId;
|
|
41
|
-
const next = jest.fn();
|
|
42
|
-
|
|
43
|
-
simpleRequestLogger(req, res, next);
|
|
44
|
-
|
|
45
|
-
expect(logger.info).toHaveBeenCalledWith(
|
|
46
|
-
expect.any(String),
|
|
47
|
-
expect.objectContaining({ correlationId: 'unknown' })
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('logs the outbound response line with status and duration once the response finishes', () => {
|
|
52
|
-
const { req, res } = mockReqRes();
|
|
53
|
-
const next = jest.fn();
|
|
54
|
-
|
|
55
|
-
simpleRequestLogger(req, res, next);
|
|
56
|
-
|
|
57
|
-
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
|
58
|
-
const finishHandler = res.on.mock.calls.find((c: any[]) => c[0] === 'finish')[1];
|
|
59
|
-
res.statusCode = 200;
|
|
60
|
-
finishHandler();
|
|
61
|
-
|
|
62
|
-
expect(logger.info).toHaveBeenCalledWith(
|
|
63
|
-
expect.stringMatching(/^← GET \/albums\/123 200 \d+ms$/),
|
|
64
|
-
expect.objectContaining({ correlationId: 'corr-1', statusCode: 200 })
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe('requestLogger', () => {
|
|
70
|
-
it('is a Morgan middleware function', () => {
|
|
71
|
-
expect(typeof requestLogger).toBe('function');
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe('requestLoggerWithMetrics', () => {
|
|
76
|
-
it('bundles requestLogger and metricsMiddleware as a two-item array', () => {
|
|
77
|
-
expect(requestLoggerWithMetrics).toHaveLength(2);
|
|
78
|
-
expect(requestLoggerWithMetrics[0]).toBe(requestLogger);
|
|
79
|
-
expect(typeof requestLoggerWithMetrics[1]).toBe('function');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import morgan from 'morgan';
|
|
2
|
-
import { Request, Response, NextFunction } from 'express';
|
|
3
|
-
import logger from '../logging/logger';
|
|
4
|
-
import { metricsMiddleware } from '../metrics';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Morgan middleware configured to use Winston logger
|
|
8
|
-
* Logs HTTP requests in structured format
|
|
9
|
-
*/
|
|
10
|
-
export const requestLogger = morgan('combined', {
|
|
11
|
-
stream: {
|
|
12
|
-
write: (message: string) => {
|
|
13
|
-
logger.http(message.trim());
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Combined middleware that includes both logging and metrics
|
|
20
|
-
* Use this instead of separate requestLogger and metricsMiddleware
|
|
21
|
-
*/
|
|
22
|
-
export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Simple request logger for development
|
|
26
|
-
* Logs basic request info with correlation ID
|
|
27
|
-
*/
|
|
28
|
-
export const simpleRequestLogger = (req: Request, res: Response, next: NextFunction) => {
|
|
29
|
-
const start = Date.now();
|
|
30
|
-
const correlationId = req.correlationId || 'unknown';
|
|
31
|
-
|
|
32
|
-
logger.info(`→ ${req.method} ${req.url}`, {
|
|
33
|
-
correlationId,
|
|
34
|
-
ip: req.ip,
|
|
35
|
-
userAgent: req.get('User-Agent')
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
res.on('finish', () => {
|
|
39
|
-
const duration = Date.now() - start;
|
|
40
|
-
logger.info(`← ${req.method} ${req.url} ${res.statusCode} ${duration}ms`, {
|
|
41
|
-
correlationId,
|
|
42
|
-
statusCode: res.statusCode,
|
|
43
|
-
duration
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
next();
|
|
48
|
-
};
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import { securityHeaders, createRateLimiter } from './security';
|
|
4
|
-
|
|
5
|
-
describe('securityHeaders', () => {
|
|
6
|
-
const app = express();
|
|
7
|
-
app.use(securityHeaders);
|
|
8
|
-
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
9
|
-
|
|
10
|
-
it('sets standard helmet protections', async () => {
|
|
11
|
-
const res = await request(app).get('/ping');
|
|
12
|
-
|
|
13
|
-
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
14
|
-
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('relaxes cross-origin-resource-policy so other-origin frontends can read the response', async () => {
|
|
18
|
-
const res = await request(app).get('/ping');
|
|
19
|
-
|
|
20
|
-
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('does not set a Content-Security-Policy header (JSON API, not HTML)', async () => {
|
|
24
|
-
const res = await request(app).get('/ping');
|
|
25
|
-
|
|
26
|
-
expect(res.headers['content-security-policy']).toBeUndefined();
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('createRateLimiter', () => {
|
|
31
|
-
it('allows requests under the limit and blocks once the limit is exceeded', async () => {
|
|
32
|
-
const app = express();
|
|
33
|
-
app.use(createRateLimiter({ windowMs: 60_000, max: 2 }));
|
|
34
|
-
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
35
|
-
|
|
36
|
-
const first = await request(app).get('/ping');
|
|
37
|
-
const second = await request(app).get('/ping');
|
|
38
|
-
const third = await request(app).get('/ping');
|
|
39
|
-
|
|
40
|
-
expect(first.status).toBe(200);
|
|
41
|
-
expect(second.status).toBe(200);
|
|
42
|
-
expect(third.status).toBe(429);
|
|
43
|
-
expect(third.body).toEqual({ success: false, message: 'Too many requests, please try again later' });
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import helmet from 'helmet';
|
|
2
|
-
import rateLimit, { Options as RateLimitOptions } from 'express-rate-limit';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Standard security headers (helmet) for all services.
|
|
6
|
-
*
|
|
7
|
-
* - `contentSecurityPolicy` is off: these are JSON APIs, not HTML apps, and a
|
|
8
|
-
* couple of services serve a static Swagger docs page under `/api/docs`
|
|
9
|
-
* whose inline scripts a default CSP would break.
|
|
10
|
-
* - `crossOriginResourcePolicy` is relaxed to `cross-origin`: helmet's default
|
|
11
|
-
* (`same-origin`) makes browsers refuse to read the response from a
|
|
12
|
-
* different origin — which every frontend is, since each front and backend
|
|
13
|
-
* service runs on its own port. CORS (see `createCorsMiddleware`) already
|
|
14
|
-
* does origin enforcement; this header must not fight it.
|
|
15
|
-
*/
|
|
16
|
-
export const securityHeaders = helmet({
|
|
17
|
-
contentSecurityPolicy: false,
|
|
18
|
-
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
19
|
-
crossOriginEmbedderPolicy: false
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Build a rate limiter with sane JSON error output. Counts per client IP.
|
|
24
|
-
*/
|
|
25
|
-
export function createRateLimiter(options: Partial<RateLimitOptions> = {}) {
|
|
26
|
-
return rateLimit({
|
|
27
|
-
windowMs: 15 * 60 * 1000,
|
|
28
|
-
max: 300,
|
|
29
|
-
standardHeaders: true,
|
|
30
|
-
legacyHeaders: false,
|
|
31
|
-
message: { success: false, message: 'Too many requests, please try again later' },
|
|
32
|
-
...options
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Baseline limiter for authenticated/general routes. */
|
|
37
|
-
export const standardRateLimiter = createRateLimiter();
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Tighter limiter for brute-force-prone unauthenticated routes (token
|
|
41
|
-
* issuance, public endpoints with no auth in front of them).
|
|
42
|
-
*/
|
|
43
|
-
export const strictRateLimiter = createRateLimiter({ max: 20 });
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { validate } from './validate';
|
|
5
|
-
import { errorHandler } from './errorHandler';
|
|
6
|
-
|
|
7
|
-
function buildApp(schema: z.ZodType) {
|
|
8
|
-
const app = express();
|
|
9
|
-
app.use(express.json());
|
|
10
|
-
app.post('/things', validate(schema), (req, res) => {
|
|
11
|
-
res.json({ success: true, data: req.body });
|
|
12
|
-
});
|
|
13
|
-
app.use(errorHandler);
|
|
14
|
-
return app;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('validate', () => {
|
|
18
|
-
const schema = z.object({
|
|
19
|
-
name: z.string().min(1),
|
|
20
|
-
age: z.number().int().positive().optional()
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('calls the handler with the parsed body when validation passes', async () => {
|
|
24
|
-
const app = buildApp(schema);
|
|
25
|
-
|
|
26
|
-
const res = await request(app).post('/things').send({ name: 'Alice', age: 30 });
|
|
27
|
-
|
|
28
|
-
expect(res.status).toBe(200);
|
|
29
|
-
expect(res.body).toEqual({ success: true, data: { name: 'Alice', age: 30 } });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('responds 400 via the shared errorHandler when a required field is missing', async () => {
|
|
33
|
-
const app = buildApp(schema);
|
|
34
|
-
|
|
35
|
-
const res = await request(app).post('/things').send({});
|
|
36
|
-
|
|
37
|
-
expect(res.status).toBe(400);
|
|
38
|
-
expect(res.body.success).toBe(false);
|
|
39
|
-
expect(res.body.error).toBe('BadRequestError');
|
|
40
|
-
expect(res.body.message).toContain('name');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('responds 400 when a field has the wrong type', async () => {
|
|
44
|
-
const app = buildApp(schema);
|
|
45
|
-
|
|
46
|
-
const res = await request(app).post('/things').send({ name: 'Bob', age: 'not-a-number' });
|
|
47
|
-
|
|
48
|
-
expect(res.status).toBe(400);
|
|
49
|
-
expect(res.body.error).toBe('BadRequestError');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('strips unknown fields not declared in the schema (zod default behavior)', async () => {
|
|
53
|
-
const app = buildApp(schema);
|
|
54
|
-
|
|
55
|
-
const res = await request(app).post('/things').send({ name: 'Carol', extra: 'nope' });
|
|
56
|
-
|
|
57
|
-
expect(res.status).toBe(200);
|
|
58
|
-
expect(res.body.data).toEqual({ name: 'Carol' });
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('omits the field prefix when the zod issue has no path (whole-body validation)', async () => {
|
|
62
|
-
const arraySchema = z.array(z.string());
|
|
63
|
-
const app = buildApp(arraySchema);
|
|
64
|
-
const rawResult = arraySchema.safeParse({ not: 'an array' });
|
|
65
|
-
|
|
66
|
-
const res = await request(app).post('/things').send({ not: 'an array' });
|
|
67
|
-
|
|
68
|
-
expect(res.status).toBe(400);
|
|
69
|
-
expect(rawResult.success).toBe(false);
|
|
70
|
-
expect(res.body.message).toBe(!rawResult.success ? rawResult.error.issues[0].message : undefined);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
2
|
-
import { ZodType } from 'zod';
|
|
3
|
-
import { BadRequestError } from '../errors/HttpError';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Validates `req.body` against a zod schema before the route handler runs.
|
|
7
|
-
* On failure, throws a `BadRequestError` (caught by the shared errorHandler)
|
|
8
|
-
* instead of letting controllers hand-roll their own field checks. On
|
|
9
|
-
* success, `req.body` is replaced with the parsed result so handlers get
|
|
10
|
-
* zod's inferred, coerced types rather than raw untyped input.
|
|
11
|
-
*/
|
|
12
|
-
export function validate(schema: ZodType): RequestHandler {
|
|
13
|
-
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
14
|
-
const result = schema.safeParse(req.body);
|
|
15
|
-
if (!result.success) {
|
|
16
|
-
const issue = result.error.issues[0];
|
|
17
|
-
const field = issue.path.length ? `${issue.path.join('.')}: ` : '';
|
|
18
|
-
throw new BadRequestError(`${field}${issue.message}`);
|
|
19
|
-
}
|
|
20
|
-
req.body = result.data;
|
|
21
|
-
next();
|
|
22
|
-
};
|
|
23
|
-
}
|
package/src/oauth/index.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export { OAuthClient, AuthorizationCode, RefreshToken } from './models';
|
|
2
|
-
export type { IOAuthClient, IAuthorizationCode, IRefreshToken } from './models';
|
|
3
|
-
export {
|
|
4
|
-
registerClient,
|
|
5
|
-
findClient,
|
|
6
|
-
isRegisteredRedirect,
|
|
7
|
-
issueAuthorizationCode,
|
|
8
|
-
redeemAuthorizationCode,
|
|
9
|
-
issueRefreshToken,
|
|
10
|
-
redeemRefreshToken,
|
|
11
|
-
revokeRefreshTokensForUser,
|
|
12
|
-
listConnections
|
|
13
|
-
} from './service';
|
|
14
|
-
export type {
|
|
15
|
-
RegisterClientInput,
|
|
16
|
-
RegisteredClient,
|
|
17
|
-
IssueCodeInput,
|
|
18
|
-
CodeRedemption,
|
|
19
|
-
CodeRejection,
|
|
20
|
-
RefreshRedemption
|
|
21
|
-
} from './service';
|
|
22
|
-
export {
|
|
23
|
-
mintAccessToken,
|
|
24
|
-
verifyAccessToken,
|
|
25
|
-
parseScopes,
|
|
26
|
-
isAccessTokenClaims,
|
|
27
|
-
ACCESS_TOKEN_TYPE
|
|
28
|
-
} from './tokens';
|
|
29
|
-
export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';
|