@tumbaland/backend-core 1.22.0 → 1.24.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/entitlements/UsageMeter.d.ts +30 -0
- package/dist/entitlements/UsageMeter.d.ts.map +1 -0
- package/dist/entitlements/UsageMeter.js +27 -0
- package/dist/entitlements/UsageMeter.js.map +1 -0
- package/dist/entitlements/client.d.ts +17 -0
- package/dist/entitlements/client.d.ts.map +1 -0
- package/dist/entitlements/client.js +142 -0
- package/dist/entitlements/client.js.map +1 -0
- package/dist/entitlements/definitions.d.ts +124 -0
- package/dist/entitlements/definitions.d.ts.map +1 -0
- package/dist/entitlements/definitions.js +210 -0
- package/dist/entitlements/definitions.js.map +1 -0
- package/dist/entitlements/index.d.ts +12 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +43 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/middleware.d.ts +54 -0
- package/dist/entitlements/middleware.d.ts.map +1 -0
- package/dist/entitlements/middleware.js +109 -0
- package/dist/entitlements/middleware.js.map +1 -0
- package/dist/entitlements/mode.d.ts +10 -0
- package/dist/entitlements/mode.d.ts.map +1 -0
- package/dist/entitlements/mode.js +32 -0
- package/dist/entitlements/mode.js.map +1 -0
- package/dist/entitlements/types.d.ts +29 -0
- package/dist/entitlements/types.d.ts.map +1 -0
- package/dist/entitlements/types.js +3 -0
- package/dist/entitlements/types.js.map +1 -0
- package/dist/entitlements/usage.d.ts +56 -0
- package/dist/entitlements/usage.d.ts.map +1 -0
- package/dist/entitlements/usage.js +159 -0
- package/dist/entitlements/usage.js.map +1 -0
- package/dist/errors/HttpError.d.ts +30 -1
- package/dist/errors/HttpError.d.ts.map +1 -1
- package/dist/errors/HttpError.js +20 -2
- package/dist/errors/HttpError.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +5 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/middleware/internalServiceAuth.d.ts +44 -0
- package/dist/middleware/internalServiceAuth.d.ts.map +1 -0
- package/dist/middleware/internalServiceAuth.js +86 -0
- package/dist/middleware/internalServiceAuth.js.map +1 -0
- package/package.json +2 -1
- package/src/entitlements/UsageMeter.ts +49 -0
- package/src/entitlements/client.test.ts +170 -0
- package/src/entitlements/client.ts +162 -0
- package/src/entitlements/definitions.test.ts +161 -0
- package/src/entitlements/definitions.ts +261 -0
- package/src/entitlements/index.ts +40 -0
- package/src/entitlements/middleware.test.ts +179 -0
- package/src/entitlements/middleware.ts +136 -0
- package/src/entitlements/mode.test.ts +50 -0
- package/src/entitlements/mode.ts +28 -0
- package/src/entitlements/types.ts +30 -0
- package/src/entitlements/usage.test.ts +271 -0
- package/src/entitlements/usage.ts +189 -0
- package/src/errors/HttpError.test.ts +39 -1
- package/src/errors/HttpError.ts +35 -1
- package/src/index.ts +13 -1
- package/src/middleware/corsMiddleware.test.ts +38 -0
- package/src/middleware/corsMiddleware.ts +5 -1
- package/src/middleware/errorHandler.test.ts +43 -1
- package/src/middleware/errorHandler.ts +6 -1
- package/src/middleware/internalServiceAuth.test.ts +173 -0
- package/src/middleware/internalServiceAuth.ts +96 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import request from 'supertest';
|
|
3
3
|
import { errorHandler } from './errorHandler';
|
|
4
|
-
import { NotFoundError, ForbiddenError } from '../errors/HttpError';
|
|
4
|
+
import { NotFoundError, ForbiddenError, PaymentRequiredError } from '../errors/HttpError';
|
|
5
5
|
|
|
6
6
|
jest.mock('../logging/logger', () => ({
|
|
7
7
|
__esModule: true,
|
|
@@ -25,6 +25,18 @@ function buildApp() {
|
|
|
25
25
|
next(namedError('ValidationError', 'CollageModel validation failed: title: Path `title` is required.'))
|
|
26
26
|
);
|
|
27
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: 'personal'
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
);
|
|
28
40
|
app.use(errorHandler);
|
|
29
41
|
return app;
|
|
30
42
|
}
|
|
@@ -143,4 +155,34 @@ describe('errorHandler', () => {
|
|
|
143
155
|
expect(res.body.error).toBe('CastError');
|
|
144
156
|
});
|
|
145
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: 'personal'
|
|
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
|
+
});
|
|
146
188
|
});
|
|
@@ -6,6 +6,8 @@ interface KnownError {
|
|
|
6
6
|
statusCode: number;
|
|
7
7
|
name: string;
|
|
8
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>;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -18,7 +20,7 @@ interface KnownError {
|
|
|
18
20
|
*/
|
|
19
21
|
function classifyError(error: Error): KnownError | null {
|
|
20
22
|
if (error instanceof HttpError) {
|
|
21
|
-
return { statusCode: error.statusCode, name: error.name, message: error.message };
|
|
23
|
+
return { statusCode: error.statusCode, name: error.name, message: error.message, details: error.details };
|
|
22
24
|
}
|
|
23
25
|
if (error.name === 'ValidationError' || error.name === 'CastError') {
|
|
24
26
|
return { statusCode: 400, name: error.name, message: error.message };
|
|
@@ -78,6 +80,9 @@ export const errorHandler: ErrorRequestHandler = (
|
|
|
78
80
|
|
|
79
81
|
if (known) {
|
|
80
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,
|
|
81
86
|
success: false,
|
|
82
87
|
error: known.name,
|
|
83
88
|
message: known.message
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
};
|