@tumbaland/backend-core 1.43.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.
Files changed (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. 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
- };