@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,91 +0,0 @@
|
|
|
1
|
-
import { Request, Response, NextFunction } from 'express';
|
|
2
|
-
import jwt from 'jsonwebtoken';
|
|
3
|
-
import { requireEnv } from '../config/env';
|
|
4
|
-
import { isAccessTokenClaims } from '../oauth/tokens';
|
|
5
|
-
import { UserPayload } from '../types/auth';
|
|
6
|
-
import { isCurrentSession } from '../auth/session';
|
|
7
|
-
import { currentGroupsOf } from '../groups/membership';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* JWT authentication middleware
|
|
11
|
-
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
12
|
-
* the request — typed via the global `Express.Request` augmentation in
|
|
13
|
-
* `../types/auth`, no `(req as any)` cast needed.
|
|
14
|
-
*
|
|
15
|
-
* OAuth access tokens are refused outright. They are signed with the same
|
|
16
|
-
* secret, so they verify here, but they mean something this middleware has no
|
|
17
|
-
* way to honour: an assistant acting within scopes, pinned to one tenant.
|
|
18
|
-
* Routes that software may legitimately reach use `authenticateAgent`, which
|
|
19
|
-
* enforces both — so anything still guarded by this one is a person's to do,
|
|
20
|
-
* and letting a token through would be handing an assistant the account.
|
|
21
|
-
*/
|
|
22
|
-
export async function authenticateToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
23
|
-
try {
|
|
24
|
-
const JWT_SECRET = requireEnv('JWT_SECRET');
|
|
25
|
-
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
26
|
-
|
|
27
|
-
if (!token) {
|
|
28
|
-
res.status(401).json({ success: false, message: 'Access token required' });
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const claims = jwt.verify(token, JWT_SECRET);
|
|
33
|
-
if (isAccessTokenClaims(claims)) {
|
|
34
|
-
res.status(403).json({
|
|
35
|
-
success: false,
|
|
36
|
-
message: 'This operation requires an interactive session, not a connected assistant'
|
|
37
|
-
});
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// A signed-out or revoked device keeps a validly signed token; only its
|
|
42
|
-
// session record says it has ended.
|
|
43
|
-
if (!(await isCurrentSession(claims))) {
|
|
44
|
-
res.status(401).json({ success: false, message: 'Session has ended' });
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
// Membership is asked of group-service, not read from the token, so a
|
|
48
|
-
// change applies within seconds rather than at the next renewal.
|
|
49
|
-
const groups = await currentGroupsOf(claims as UserPayload);
|
|
50
|
-
req.user = { ...(claims as UserPayload), groups };
|
|
51
|
-
req.userGroups = groups;
|
|
52
|
-
} catch (error) {
|
|
53
|
-
res.status(401).json({ success: false, message: 'Invalid or expired token' });
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
next();
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Attaches the user when a valid token is present, and never rejects.
|
|
60
|
-
*
|
|
61
|
-
* For routes whose response is public but *richer* when signed in — the plan
|
|
62
|
-
* catalog is the case this exists for: a signed-out visitor on the landing
|
|
63
|
-
* page must be able to read prices, while a signed-in one additionally gets
|
|
64
|
-
* the hidden plan they are actually on.
|
|
65
|
-
*
|
|
66
|
-
* An invalid or expired token is treated as no token rather than as a 401. The
|
|
67
|
-
* caller asked for public data; refusing it because a stale cookie is lying
|
|
68
|
-
* around would break the landing page for exactly the people most likely to
|
|
69
|
-
* have one.
|
|
70
|
-
*/
|
|
71
|
-
export async function optionalAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
|
72
|
-
try {
|
|
73
|
-
const JWT_SECRET = requireEnv('JWT_SECRET');
|
|
74
|
-
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
75
|
-
|
|
76
|
-
if (token) {
|
|
77
|
-
const claims = jwt.verify(token, JWT_SECRET);
|
|
78
|
-
// An access token is not a session; treated as none, exactly like an
|
|
79
|
-
// expired cookie, so the route still answers with its public form.
|
|
80
|
-
// An ended session is treated as none, like an expired cookie.
|
|
81
|
-
if (!isAccessTokenClaims(claims) && (await isCurrentSession(claims))) {
|
|
82
|
-
const groups = await currentGroupsOf(claims as UserPayload);
|
|
83
|
-
req.user = { ...(claims as UserPayload), groups };
|
|
84
|
-
req.userGroups = groups;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
// Deliberately ignored — see above.
|
|
89
|
-
}
|
|
90
|
-
next();
|
|
91
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import { createCorsMiddleware } from './corsMiddleware';
|
|
4
|
-
import { errorHandler } from './errorHandler';
|
|
5
|
-
|
|
6
|
-
jest.mock('../logging/logger', () => ({
|
|
7
|
-
__esModule: true,
|
|
8
|
-
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
function buildApp(origins: string) {
|
|
12
|
-
const app = express();
|
|
13
|
-
app.use(createCorsMiddleware({ origins }));
|
|
14
|
-
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
15
|
-
app.use(errorHandler);
|
|
16
|
-
return app;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('createCorsMiddleware', () => {
|
|
20
|
-
const app = buildApp('https://allowed.example,https://also-allowed.example');
|
|
21
|
-
|
|
22
|
-
it('allows a request with no Origin header (server-to-server / health checks)', async () => {
|
|
23
|
-
const res = await request(app).get('/ping');
|
|
24
|
-
expect(res.status).toBe(200);
|
|
25
|
-
expect(res.body).toEqual({ ok: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('allows a whitelisted origin and echoes it back with credentials', async () => {
|
|
29
|
-
const res = await request(app).get('/ping').set('Origin', 'https://allowed.example');
|
|
30
|
-
|
|
31
|
-
expect(res.status).toBe(200);
|
|
32
|
-
expect(res.headers['access-control-allow-origin']).toBe('https://allowed.example');
|
|
33
|
-
expect(res.headers['access-control-allow-credentials']).toBe('true');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('rejects an origin not on the whitelist', async () => {
|
|
37
|
-
const res = await request(app).get('/ping').set('Origin', 'https://evil.example');
|
|
38
|
-
|
|
39
|
-
expect(res.status).toBe(500);
|
|
40
|
-
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('the preflight', () => {
|
|
44
|
-
const preflight = (method: string) =>
|
|
45
|
-
request(app)
|
|
46
|
-
.options('/ping')
|
|
47
|
-
.set('Origin', 'https://allowed.example')
|
|
48
|
-
.set('Access-Control-Request-Method', method);
|
|
49
|
-
|
|
50
|
-
// A method left out here is the worst kind of CORS failure: the preflight
|
|
51
|
-
// returns 204, so the network tab shows nothing failing, and the browser
|
|
52
|
-
// quietly refuses to send the request it just asked about.
|
|
53
|
-
it.each([['GET'], ['POST'], ['PUT'], ['PATCH'], ['DELETE']])('permits %s', async (method) => {
|
|
54
|
-
const res = await preflight(method);
|
|
55
|
-
|
|
56
|
-
expect(res.headers['access-control-allow-methods'].split(',')).toContain(method);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('does not advertise a method the services do not serve', async () => {
|
|
60
|
-
const res = await preflight('GET');
|
|
61
|
-
|
|
62
|
-
expect(res.headers['access-control-allow-methods']).not.toContain('TRACE');
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('exposes only the response headers a caller asks for', async () => {
|
|
67
|
-
// Cross-origin JavaScript cannot read a header that is not exposed — the
|
|
68
|
-
// CSV export reads its filename out of Content-Disposition
|
|
69
|
-
const exposing = express();
|
|
70
|
-
exposing.use(createCorsMiddleware({
|
|
71
|
-
origins: 'https://allowed.example',
|
|
72
|
-
exposedHeaders: ['Content-Disposition']
|
|
73
|
-
}));
|
|
74
|
-
exposing.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
75
|
-
|
|
76
|
-
const res = await request(exposing).get('/ping').set('Origin', 'https://allowed.example');
|
|
77
|
-
|
|
78
|
-
expect(res.headers['access-control-expose-headers']).toBe('Content-Disposition');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('falls back to process.env.CORS_ORIGIN when no origins option is given', async () => {
|
|
82
|
-
const ORIGINAL_ENV = process.env;
|
|
83
|
-
process.env = { ...ORIGINAL_ENV, CORS_ORIGIN: 'https://from-env.example' };
|
|
84
|
-
|
|
85
|
-
const envApp = express();
|
|
86
|
-
envApp.use(createCorsMiddleware());
|
|
87
|
-
envApp.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
88
|
-
envApp.use(errorHandler);
|
|
89
|
-
|
|
90
|
-
const res = await request(envApp).get('/ping').set('Origin', 'https://from-env.example');
|
|
91
|
-
expect(res.status).toBe(200);
|
|
92
|
-
expect(res.headers['access-control-allow-origin']).toBe('https://from-env.example');
|
|
93
|
-
|
|
94
|
-
process.env = ORIGINAL_ENV;
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('bypassPaths', () => {
|
|
99
|
-
const run = (middleware: ReturnType<typeof createCorsMiddleware>, path: string, origin?: string) => {
|
|
100
|
-
const req = { path, method: 'POST', headers: origin ? { origin } : {} } as never;
|
|
101
|
-
const res = { setHeader: jest.fn(), getHeader: jest.fn(), end: jest.fn() } as never;
|
|
102
|
-
const next = jest.fn();
|
|
103
|
-
middleware(req, res, next);
|
|
104
|
-
return next;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
it('lets a bypassed path through with an origin that would otherwise be blocked', () => {
|
|
108
|
-
// Firefox sends a literal `Origin: null` on the consent form POST after the
|
|
109
|
-
// login redirect. It is truthy, matches no whitelist entry, and turned an
|
|
110
|
-
// approved consent into a 500.
|
|
111
|
-
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
112
|
-
|
|
113
|
-
const next = run(middleware, '/oauth/authorize', 'null');
|
|
114
|
-
|
|
115
|
-
expect(next).toHaveBeenCalledWith();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('still checks the origin on every other path', () => {
|
|
119
|
-
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
120
|
-
|
|
121
|
-
const next = run(middleware, '/auth/profile', 'null');
|
|
122
|
-
|
|
123
|
-
expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('CORS') }));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('matches by prefix, so nested oauth routes are covered too', () => {
|
|
127
|
-
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
128
|
-
expect(run(middleware, '/oauth/token', 'null')).toHaveBeenCalledWith();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('changes nothing when no bypass is configured', () => {
|
|
132
|
-
const middleware = createCorsMiddleware({ origins: 'https://app.example' });
|
|
133
|
-
expect(run(middleware, '/oauth/authorize', 'null')).toHaveBeenCalledWith(expect.any(Error));
|
|
134
|
-
});
|
|
135
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import cors from 'cors';
|
|
2
|
-
import type { RequestHandler } from 'express';
|
|
3
|
-
import logger from '../logging/logger';
|
|
4
|
-
|
|
5
|
-
export interface CorsMiddlewareOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Comma-separated origin whitelist. Defaults to process.env.CORS_ORIGIN,
|
|
8
|
-
* read per-request so it works regardless of dotenv.config() timing.
|
|
9
|
-
*/
|
|
10
|
-
origins?: string;
|
|
11
|
-
exposedHeaders?: string[];
|
|
12
|
-
/**
|
|
13
|
-
* Path prefixes that skip the origin check entirely.
|
|
14
|
-
*
|
|
15
|
-
* For endpoints the browser *navigates* to rather than fetches — an OAuth
|
|
16
|
-
* authorize page and the consent form it posts back. CORS governs
|
|
17
|
-
* script-initiated cross-origin reads and has nothing to say about a
|
|
18
|
-
* top-level form submission, but it still blocks one: Firefox sends a
|
|
19
|
-
* literal `Origin: null` after a cross-site redirect chain, which is truthy,
|
|
20
|
-
* matches no whitelist entry, and turns a legitimate consent into a 500.
|
|
21
|
-
*
|
|
22
|
-
* Nothing is weakened by skipping them. These routes carry no XHR-readable
|
|
23
|
-
* data, and what actually protects the consent POST is its signed consent
|
|
24
|
-
* token, not the origin header.
|
|
25
|
-
*/
|
|
26
|
-
bypassPaths?: string[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Shared CORS middleware with an explicit origin whitelist.
|
|
31
|
-
* Requests without an Origin header (server-to-server, curl, health checks)
|
|
32
|
-
* are always allowed; browser requests must match the whitelist.
|
|
33
|
-
*/
|
|
34
|
-
export function createCorsMiddleware(options: CorsMiddlewareOptions = {}): RequestHandler {
|
|
35
|
-
const bypassPaths = options.bypassPaths ?? [];
|
|
36
|
-
|
|
37
|
-
const corsHandler = cors({
|
|
38
|
-
origin: (origin, callback) => {
|
|
39
|
-
const allowedOrigins = (options.origins ?? process.env.CORS_ORIGIN ?? '')
|
|
40
|
-
.split(',')
|
|
41
|
-
.map(o => o.trim())
|
|
42
|
-
.filter(Boolean);
|
|
43
|
-
if (!origin || allowedOrigins.includes(origin)) {
|
|
44
|
-
return callback(null, true);
|
|
45
|
-
}
|
|
46
|
-
logger.warn('Blocked CORS origin', { origin });
|
|
47
|
-
return callback(new Error('Not allowed by CORS: ' + origin));
|
|
48
|
-
},
|
|
49
|
-
credentials: true,
|
|
50
|
-
// PATCH belongs here as much as PUT: leaving it out lets the preflight
|
|
51
|
-
// succeed and then has the browser block the request it just approved,
|
|
52
|
-
// which surfaces in the UI as a bare "failed to save" with a 204 in the
|
|
53
|
-
// network tab and no failing request to point at.
|
|
54
|
-
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
55
|
-
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id', 'x-session-id'],
|
|
56
|
-
exposedHeaders: options.exposedHeaders
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (bypassPaths.length === 0) return corsHandler as RequestHandler;
|
|
60
|
-
|
|
61
|
-
return (req, res, next) => {
|
|
62
|
-
if (bypassPaths.some((prefix) => req.path.startsWith(prefix))) return next();
|
|
63
|
-
return (corsHandler as RequestHandler)(req, res, next);
|
|
64
|
-
};
|
|
65
|
-
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import request from 'supertest';
|
|
3
|
-
import { errorHandler } from './errorHandler';
|
|
4
|
-
import { NotFoundError, ForbiddenError, PaymentRequiredError } from '../errors/HttpError';
|
|
5
|
-
|
|
6
|
-
jest.mock('../logging/logger', () => ({
|
|
7
|
-
__esModule: true,
|
|
8
|
-
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
import logger from '../logging/logger';
|
|
12
|
-
|
|
13
|
-
function namedError(name: string, message: string): Error {
|
|
14
|
-
const error = new Error(message);
|
|
15
|
-
error.name = name;
|
|
16
|
-
return error;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function buildApp() {
|
|
20
|
-
const app = express();
|
|
21
|
-
app.get('/boom', (_req, _res, next) => next(new Error('kaboom')));
|
|
22
|
-
app.get('/missing', (_req, _res, next) => next(new NotFoundError('Album not found')));
|
|
23
|
-
app.get('/forbidden', (_req, _res, next) => next(new ForbiddenError()));
|
|
24
|
-
app.get('/validation', (_req, _res, next) =>
|
|
25
|
-
next(namedError('ValidationError', 'CollageModel validation failed: title: Path `title` is required.'))
|
|
26
|
-
);
|
|
27
|
-
app.get('/cast', (_req, _res, next) => next(namedError('CastError', 'Cast to ObjectId failed for value "abc"')));
|
|
28
|
-
app.get('/over-quota', (_req, _res, next) =>
|
|
29
|
-
next(
|
|
30
|
-
new PaymentRequiredError('Storage limit reached', {
|
|
31
|
-
code: 'QUOTA_EXCEEDED',
|
|
32
|
-
meter: 'storageBytes',
|
|
33
|
-
used: 1073741824,
|
|
34
|
-
limit: 1073741824,
|
|
35
|
-
planCode: 'free',
|
|
36
|
-
upgradeTo: 'support'
|
|
37
|
-
})
|
|
38
|
-
)
|
|
39
|
-
);
|
|
40
|
-
app.use(errorHandler);
|
|
41
|
-
return app;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe('errorHandler', () => {
|
|
45
|
-
const ORIGINAL_ENV = process.env;
|
|
46
|
-
|
|
47
|
-
beforeEach(() => {
|
|
48
|
-
process.env = { ...ORIGINAL_ENV };
|
|
49
|
-
(logger.error as jest.Mock).mockClear();
|
|
50
|
-
(logger.warn as jest.Mock).mockClear();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
afterAll(() => {
|
|
54
|
-
process.env = ORIGINAL_ENV;
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('logs the error and responds 500 with a generic message in production', async () => {
|
|
58
|
-
process.env.NODE_ENV = 'production';
|
|
59
|
-
const app = buildApp();
|
|
60
|
-
|
|
61
|
-
const res = await request(app).get('/boom');
|
|
62
|
-
|
|
63
|
-
expect(res.status).toBe(500);
|
|
64
|
-
expect(res.body).toEqual({
|
|
65
|
-
success: false,
|
|
66
|
-
error: 'Internal server error',
|
|
67
|
-
message: 'Something went wrong'
|
|
68
|
-
});
|
|
69
|
-
expect(logger.error).toHaveBeenCalledWith('Unhandled error:', expect.objectContaining({ error: 'kaboom' }));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('includes the real error message and stack in development', async () => {
|
|
73
|
-
process.env.NODE_ENV = 'development';
|
|
74
|
-
const app = buildApp();
|
|
75
|
-
|
|
76
|
-
const res = await request(app).get('/boom');
|
|
77
|
-
|
|
78
|
-
expect(res.status).toBe(500);
|
|
79
|
-
expect(res.body.message).toBe('kaboom');
|
|
80
|
-
expect(res.body.stack).toBeDefined();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('adds CORS headers to the error response for an allowed origin', async () => {
|
|
84
|
-
process.env.NODE_ENV = 'production';
|
|
85
|
-
const app = buildApp();
|
|
86
|
-
|
|
87
|
-
const res = await request(app).get('/boom').set('Origin', 'http://localhost:3000');
|
|
88
|
-
|
|
89
|
-
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000');
|
|
90
|
-
expect(res.headers['access-control-allow-credentials']).toBe('true');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('does not add CORS headers for a disallowed origin', async () => {
|
|
94
|
-
process.env.NODE_ENV = 'production';
|
|
95
|
-
const app = buildApp();
|
|
96
|
-
|
|
97
|
-
const res = await request(app).get('/boom').set('Origin', 'https://evil.example');
|
|
98
|
-
|
|
99
|
-
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('typed HttpError instances', () => {
|
|
103
|
-
it('uses the error’s own status code and message, in production too', async () => {
|
|
104
|
-
process.env.NODE_ENV = 'production';
|
|
105
|
-
const app = buildApp();
|
|
106
|
-
|
|
107
|
-
const res = await request(app).get('/missing');
|
|
108
|
-
|
|
109
|
-
expect(res.status).toBe(404);
|
|
110
|
-
expect(res.body).toEqual({ success: false, error: 'NotFoundError', message: 'Album not found' });
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('logs at warn level, not error, and without a stack trace', async () => {
|
|
114
|
-
const app = buildApp();
|
|
115
|
-
|
|
116
|
-
await request(app).get('/forbidden');
|
|
117
|
-
|
|
118
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
119
|
-
'Request failed:',
|
|
120
|
-
expect.objectContaining({ error: 'Access denied', statusCode: 403 })
|
|
121
|
-
);
|
|
122
|
-
expect(logger.error).not.toHaveBeenCalled();
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('does not include a stack trace in the response body, even in development', async () => {
|
|
126
|
-
process.env.NODE_ENV = 'development';
|
|
127
|
-
const app = buildApp();
|
|
128
|
-
|
|
129
|
-
const res = await request(app).get('/missing');
|
|
130
|
-
|
|
131
|
-
expect(res.body.stack).toBeUndefined();
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('Mongoose ValidationError / CastError', () => {
|
|
136
|
-
it('maps a ValidationError to 400, using its own message', async () => {
|
|
137
|
-
const app = buildApp();
|
|
138
|
-
|
|
139
|
-
const res = await request(app).get('/validation');
|
|
140
|
-
|
|
141
|
-
expect(res.status).toBe(400);
|
|
142
|
-
expect(res.body).toEqual({
|
|
143
|
-
success: false,
|
|
144
|
-
error: 'ValidationError',
|
|
145
|
-
message: 'CollageModel validation failed: title: Path `title` is required.'
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('maps a CastError (e.g. an invalid ObjectId) to 400', async () => {
|
|
150
|
-
const app = buildApp();
|
|
151
|
-
|
|
152
|
-
const res = await request(app).get('/cast');
|
|
153
|
-
|
|
154
|
-
expect(res.status).toBe(400);
|
|
155
|
-
expect(res.body.error).toBe('CastError');
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('PaymentRequiredError', () => {
|
|
160
|
-
it('serializes the details the upgrade dialog needs alongside the envelope', async () => {
|
|
161
|
-
const app = buildApp();
|
|
162
|
-
|
|
163
|
-
const res = await request(app).get('/over-quota');
|
|
164
|
-
|
|
165
|
-
expect(res.status).toBe(402);
|
|
166
|
-
expect(res.body).toEqual({
|
|
167
|
-
success: false,
|
|
168
|
-
error: 'PaymentRequiredError',
|
|
169
|
-
message: 'Storage limit reached',
|
|
170
|
-
code: 'QUOTA_EXCEEDED',
|
|
171
|
-
meter: 'storageBytes',
|
|
172
|
-
used: 1073741824,
|
|
173
|
-
limit: 1073741824,
|
|
174
|
-
planCode: 'free',
|
|
175
|
-
upgradeTo: 'support'
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('logs a paywall hit as routine control flow, not as a server fault', async () => {
|
|
180
|
-
const app = buildApp();
|
|
181
|
-
|
|
182
|
-
await request(app).get('/over-quota');
|
|
183
|
-
|
|
184
|
-
expect(logger.error).not.toHaveBeenCalled();
|
|
185
|
-
expect(logger.warn).toHaveBeenCalledWith('Request failed:', expect.objectContaining({ statusCode: 402 }));
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
|
|
2
|
-
import logger from '../logging/logger';
|
|
3
|
-
import { HttpError } from '../errors/HttpError';
|
|
4
|
-
|
|
5
|
-
interface KnownError {
|
|
6
|
-
statusCode: number;
|
|
7
|
-
name: string;
|
|
8
|
-
message: string;
|
|
9
|
-
/** Extra body fields carried by errors the client must act on (e.g. a 402's meter/limit/upgradeTo). */
|
|
10
|
-
details?: Record<string, unknown>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Recognizes error shapes that map to an obvious HTTP status without a
|
|
15
|
-
* controller having to catch and re-throw them: our own typed HttpError
|
|
16
|
-
* hierarchy, plus Mongoose's ValidationError (failed schema validation,
|
|
17
|
-
* e.g. a missing required field) and CastError (e.g. an invalid ObjectId
|
|
18
|
-
* string) — both of which are client mistakes, not server bugs, but throw
|
|
19
|
-
* as plain Mongoose error classes rather than anything of ours.
|
|
20
|
-
*/
|
|
21
|
-
function classifyError(error: Error): KnownError | null {
|
|
22
|
-
if (error instanceof HttpError) {
|
|
23
|
-
return { statusCode: error.statusCode, name: error.name, message: error.message, details: error.details };
|
|
24
|
-
}
|
|
25
|
-
if (error.name === 'ValidationError' || error.name === 'CastError') {
|
|
26
|
-
return { statusCode: 400, name: error.name, message: error.message };
|
|
27
|
-
}
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Global error handler middleware.
|
|
33
|
-
*
|
|
34
|
-
* Controllers throw a typed `HttpError` (BadRequestError, NotFoundError,
|
|
35
|
-
* ForbiddenError, ...) instead of hand-rolling try/catch + string-matching
|
|
36
|
-
* on `error.message` to pick a status code — Express 5 forwards rejected
|
|
37
|
-
* promises from async route handlers here automatically. Anything that
|
|
38
|
-
* isn't a recognized error type is treated as an unexpected bug: logged
|
|
39
|
-
* with its full stack and returned as a generic 500 (no message leak
|
|
40
|
-
* outside development), same as before this file knew about HttpError.
|
|
41
|
-
*/
|
|
42
|
-
export const errorHandler: ErrorRequestHandler = (
|
|
43
|
-
error: Error,
|
|
44
|
-
req: Request,
|
|
45
|
-
res: Response,
|
|
46
|
-
_next: NextFunction
|
|
47
|
-
): void => {
|
|
48
|
-
const known = classifyError(error);
|
|
49
|
-
|
|
50
|
-
if (known) {
|
|
51
|
-
// Client errors (4xx) are routine, expected control flow — log without
|
|
52
|
-
// a stack trace at warn level, not as if the service were broken.
|
|
53
|
-
logger.warn('Request failed:', {
|
|
54
|
-
error: known.message,
|
|
55
|
-
statusCode: known.statusCode,
|
|
56
|
-
url: req.url,
|
|
57
|
-
method: req.method,
|
|
58
|
-
correlationId: req.correlationId
|
|
59
|
-
});
|
|
60
|
-
} else {
|
|
61
|
-
logger.error('Unhandled error:', {
|
|
62
|
-
error: error.message,
|
|
63
|
-
stack: error.stack,
|
|
64
|
-
url: req.url,
|
|
65
|
-
method: req.method,
|
|
66
|
-
ip: req.ip,
|
|
67
|
-
userAgent: req.get('User-Agent'),
|
|
68
|
-
correlationId: req.correlationId
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Add CORS headers to error responses
|
|
73
|
-
const origin = req.headers.origin;
|
|
74
|
-
if (origin && (origin.includes('localhost') || origin.includes('tumbaland.eu'))) {
|
|
75
|
-
res.header('Access-Control-Allow-Origin', origin);
|
|
76
|
-
res.header('Access-Control-Allow-Credentials', 'true');
|
|
77
|
-
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
78
|
-
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-correlation-id, x-session-id');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (known) {
|
|
82
|
-
res.status(known.statusCode).json({
|
|
83
|
-
// Spread first so a stray details key can't shadow the envelope fields
|
|
84
|
-
// every client already relies on.
|
|
85
|
-
...known.details,
|
|
86
|
-
success: false,
|
|
87
|
-
error: known.name,
|
|
88
|
-
message: known.message
|
|
89
|
-
});
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Don't leak error details in production for unexpected errors
|
|
94
|
-
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
95
|
-
const errorMessage = isDevelopment ? error.message : 'Something went wrong';
|
|
96
|
-
|
|
97
|
-
res.status(500).json({
|
|
98
|
-
success: false,
|
|
99
|
-
error: 'Internal server error',
|
|
100
|
-
message: errorMessage,
|
|
101
|
-
...(isDevelopment && { stack: error.stack })
|
|
102
|
-
});
|
|
103
|
-
};
|