@hubspot/ui-extensions-dev-server 1.1.6 → 1.1.7
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/lib/DevServerState.d.ts +1 -1
- package/dist/lib/ExtensionsWebSocket.js +26 -2
- package/dist/lib/__tests__/ExtensionsWebSocket.spec.js +42 -8
- package/dist/lib/__tests__/app-functions/context.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/context.spec.js +101 -0
- package/dist/lib/__tests__/app-functions/errorReporter.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/errorReporter.spec.js +102 -0
- package/dist/lib/__tests__/app-functions/executor_v20231.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/executor_v20231.spec.js +168 -0
- package/dist/lib/__tests__/app-functions/executor_v20232.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/executor_v20232.spec.js +190 -0
- package/dist/lib/__tests__/app-functions/fixtures/constants.d.ts +18 -0
- package/dist/lib/__tests__/app-functions/fixtures/constants.js +139 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.cjs +14 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.cjs +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.cjs +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.d.cts +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.cjs +12 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.cjs +14 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.d.cts +4 -0
- package/dist/lib/__tests__/app-functions/secrets.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/secrets.spec.js +278 -0
- package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.js +667 -0
- package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.js +243 -0
- package/dist/lib/__tests__/app-functions/services/services_v20231.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/services_v20231.spec.js +319 -0
- package/dist/lib/__tests__/app-functions/services/services_v20232.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/services_v20232.spec.js +302 -0
- package/dist/lib/__tests__/app-functions/setup.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/setup.js +7 -0
- package/dist/lib/__tests__/app-functions/signing.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/signing.spec.js +460 -0
- package/dist/lib/__tests__/server.spec.js +24 -2
- package/dist/lib/app-functions/api/privateAppUserToken.d.ts +16 -0
- package/dist/lib/app-functions/api/privateAppUserToken.js +28 -0
- package/dist/lib/app-functions/config.d.ts +4 -0
- package/dist/lib/app-functions/config.js +48 -0
- package/dist/lib/app-functions/constants.d.ts +26 -0
- package/dist/lib/app-functions/constants.js +63 -0
- package/dist/lib/app-functions/context.d.ts +3 -0
- package/dist/lib/app-functions/context.js +65 -0
- package/dist/lib/app-functions/errorReporter.d.ts +22 -0
- package/dist/lib/app-functions/errorReporter.js +42 -0
- package/dist/lib/app-functions/errors.d.ts +44 -0
- package/dist/lib/app-functions/errors.js +82 -0
- package/dist/lib/app-functions/executor.d.ts +3 -0
- package/dist/lib/app-functions/executor.js +131 -0
- package/dist/lib/app-functions/index.d.ts +4 -0
- package/dist/lib/app-functions/index.js +4 -0
- package/dist/lib/app-functions/secrets.d.ts +5 -0
- package/dist/lib/app-functions/secrets.js +55 -0
- package/dist/lib/app-functions/services/AppFunctionExecutionService.d.ts +2 -0
- package/dist/lib/app-functions/services/AppFunctionExecutionService.js +55 -0
- package/dist/lib/app-functions/services/AppProxyService.d.ts +5 -0
- package/dist/lib/app-functions/services/AppProxyService.js +196 -0
- package/dist/lib/app-functions/services/PrivateAppUserTokenManager.d.ts +22 -0
- package/dist/lib/app-functions/services/PrivateAppUserTokenManager.js +185 -0
- package/dist/lib/app-functions/services/constants.d.ts +4 -0
- package/dist/lib/app-functions/services/constants.js +4 -0
- package/dist/lib/app-functions/services/index.d.ts +3 -0
- package/dist/lib/app-functions/services/index.js +3 -0
- package/dist/lib/app-functions/services/messages.d.ts +14 -0
- package/dist/lib/app-functions/services/messages.js +36 -0
- package/dist/lib/app-functions/signing.d.ts +29 -0
- package/dist/lib/app-functions/signing.js +51 -0
- package/dist/lib/app-functions/types.d.ts +172 -0
- package/dist/lib/app-functions/types.js +6 -0
- package/dist/lib/app-functions/utils.d.ts +15 -0
- package/dist/lib/app-functions/utils.js +28 -0
- package/dist/lib/server.js +15 -4
- package/dist/lib/types.d.ts +1 -1
- package/package.json +9 -6
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import axios, { isAxiosError } from 'axios';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { axiosErrorMappings, defaultServerError, localProxyErrorMappings, } from "../constants.js";
|
|
6
|
+
import { getSignatureHeaders } from "../signing.js";
|
|
7
|
+
import { reportError } from "../errorReporter.js";
|
|
8
|
+
export const mapToLocalUrl = (localDevUrlMapping, requestUri, allowedUrls, logger) => {
|
|
9
|
+
const url = new URL(requestUri);
|
|
10
|
+
if (allowedUrls && !allowedUrls.includes(url.origin)) {
|
|
11
|
+
logger.warn(`'${url.origin}' is not in the 'allowedUrls' list in the application configuration file. Uploading the project in it's current state may result in request failures.`);
|
|
12
|
+
}
|
|
13
|
+
const localMapping = localDevUrlMapping[url.origin] || localDevUrlMapping[`${url.origin}/`];
|
|
14
|
+
if (!localMapping) {
|
|
15
|
+
return requestUri;
|
|
16
|
+
}
|
|
17
|
+
const parsedLocalMapping = new URL(localMapping);
|
|
18
|
+
url.host = parsedLocalMapping.host;
|
|
19
|
+
url.protocol = parsedLocalMapping.protocol;
|
|
20
|
+
// The local mapping pathname is just slash, there is no need to merge
|
|
21
|
+
if (parsedLocalMapping.pathname !== '/') {
|
|
22
|
+
// If the local pathname ends with '/', remove it to avoid '//' in the path
|
|
23
|
+
const localPathname = parsedLocalMapping.pathname.endsWith('/')
|
|
24
|
+
? parsedLocalMapping.pathname.slice(0, 1)
|
|
25
|
+
: parsedLocalMapping.pathname;
|
|
26
|
+
url.pathname = `${localPathname}${url.pathname}`;
|
|
27
|
+
}
|
|
28
|
+
const result = url.toString();
|
|
29
|
+
// If the result ends with a '/' and the user didn't add it, remove it
|
|
30
|
+
if (result.endsWith('/') && !localMapping.endsWith('/')) {
|
|
31
|
+
return result.slice(0, -1);
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
export const extractErrorData = (e) => {
|
|
36
|
+
// If the error is not an AxiosError, it was an error with the local proxy
|
|
37
|
+
// Note: reporting is handled by the caller to provide full context
|
|
38
|
+
if (!isAxiosError(e)) {
|
|
39
|
+
return defaultServerError;
|
|
40
|
+
}
|
|
41
|
+
// Check if we have a mapping, otherwise fallback to the the default error
|
|
42
|
+
let errorData = axiosErrorMappings[e.code || ''] || defaultServerError;
|
|
43
|
+
if (e.response) {
|
|
44
|
+
const { status, data } = e.response;
|
|
45
|
+
errorData = { ...errorData, status, data };
|
|
46
|
+
}
|
|
47
|
+
return errorData;
|
|
48
|
+
};
|
|
49
|
+
export const AppProxyService = ({ localDevUrlMapping, logger, accountId, allowedUrls, }) => {
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(cors());
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
app.post('/proxy', async (req, res) => {
|
|
54
|
+
const correlationId = crypto.randomUUID();
|
|
55
|
+
const { requestUri, method, requestTimeoutMillis, requestBody, requestHeaders, } =
|
|
56
|
+
/* eslint-disable-next-line no-unsafe-optional-chaining */
|
|
57
|
+
req?.body;
|
|
58
|
+
try {
|
|
59
|
+
logger.info(`Request to ${requestUri} started, method=${method}, correlationId=${correlationId}`);
|
|
60
|
+
const url = mapToLocalUrl(localDevUrlMapping, requestUri, allowedUrls, logger);
|
|
61
|
+
logger.info(url === requestUri
|
|
62
|
+
? `No local mapping found, using ${requestUri}, correlationId=${correlationId}`
|
|
63
|
+
: `Mapping ${requestUri} -> ${url}, correlationId=${correlationId}`);
|
|
64
|
+
const headerKeys = Object.keys(requestHeaders || {});
|
|
65
|
+
const tooManyHeaders = headerKeys.length > 1;
|
|
66
|
+
/**
|
|
67
|
+
* Mirroring what the backend is doing
|
|
68
|
+
* https://git.hubteam.com/HubSpot/CRMExtensibility/blob/082c4468415dfa8492766e8eec59c072aed9a652/CRMExtensibilityExecutionService/src/main/java/com/hubspot/crm/extensibility/service/util/proxy/ProxyError.java#L23
|
|
69
|
+
*/
|
|
70
|
+
if (tooManyHeaders) {
|
|
71
|
+
const status = 400;
|
|
72
|
+
const message = "Only 'Authorization' header is allowed";
|
|
73
|
+
logger.warn(message);
|
|
74
|
+
res.status(status).send({
|
|
75
|
+
category: 'VALIDATION_ERROR',
|
|
76
|
+
status: 'error',
|
|
77
|
+
message,
|
|
78
|
+
context: {
|
|
79
|
+
correlationId: [correlationId],
|
|
80
|
+
httpMethod: [method],
|
|
81
|
+
portalId: [`${accountId}`],
|
|
82
|
+
requestUri: [requestUri],
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const nonAuthHeader = headerKeys.some((header) => 'authorization' !== header.toLowerCase());
|
|
88
|
+
/**
|
|
89
|
+
* Mirroring what the backend is doing
|
|
90
|
+
* https://git.hubteam.com/HubSpot/CRMExtensibility/blob/082c4468415dfa8492766e8eec59c072aed9a652/CRMExtensibilityExecutionService/src/main/java/com/hubspot/crm/extensibility/service/util/proxy/ProxyError.java#L15
|
|
91
|
+
*/
|
|
92
|
+
if (nonAuthHeader) {
|
|
93
|
+
const status = 400;
|
|
94
|
+
const message = "Invalid Request Headers provided. Only 'Authorization' header is allowed from hubspot.fetch()";
|
|
95
|
+
logger.warn(message);
|
|
96
|
+
res.status(status).send({
|
|
97
|
+
category: 'VALIDATION_ERROR',
|
|
98
|
+
status: 'error',
|
|
99
|
+
message,
|
|
100
|
+
context: {
|
|
101
|
+
correlationId: [correlationId],
|
|
102
|
+
httpMethod: [method],
|
|
103
|
+
portalId: [`${accountId}`],
|
|
104
|
+
requestUri: [requestUri],
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const methodsWithBody = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
110
|
+
const shouldIncludeBody = methodsWithBody.includes(method.toUpperCase()) &&
|
|
111
|
+
requestBody !== undefined &&
|
|
112
|
+
requestBody !== null;
|
|
113
|
+
const { status, data: responseBody } = await axios.request({
|
|
114
|
+
url,
|
|
115
|
+
method,
|
|
116
|
+
timeout: requestTimeoutMillis,
|
|
117
|
+
...(shouldIncludeBody && { data: JSON.stringify(requestBody) }),
|
|
118
|
+
headers: {
|
|
119
|
+
...requestHeaders,
|
|
120
|
+
...getSignatureHeaders({
|
|
121
|
+
method,
|
|
122
|
+
url,
|
|
123
|
+
...(shouldIncludeBody && { requestBody }),
|
|
124
|
+
}),
|
|
125
|
+
...(shouldIncludeBody && { 'content-type': 'application/json' }),
|
|
126
|
+
},
|
|
127
|
+
maxRedirects: 0,
|
|
128
|
+
});
|
|
129
|
+
res.status(status).send({
|
|
130
|
+
status: 'success',
|
|
131
|
+
responseBody,
|
|
132
|
+
context: {
|
|
133
|
+
correlationId: [correlationId],
|
|
134
|
+
httpMethod: [method],
|
|
135
|
+
appServerStatusCode: [`${status}`],
|
|
136
|
+
portalId: [`${accountId}`],
|
|
137
|
+
requestUri: [requestUri],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
const { status, message, category, data: responseBody, } = extractErrorData(e);
|
|
143
|
+
// Report non-Axios errors (dev server bugs)
|
|
144
|
+
if (!isAxiosError(e)) {
|
|
145
|
+
reportError(e, {
|
|
146
|
+
errorType: 'local_proxy_error',
|
|
147
|
+
accountId,
|
|
148
|
+
requestUri,
|
|
149
|
+
method,
|
|
150
|
+
correlationId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// This is mirroring current backend proxy behavior
|
|
154
|
+
if (status >= 300 && status <= 599) {
|
|
155
|
+
const { status: proxyStatus, message: proxyMessage, category: proxyCategory, } = localProxyErrorMappings.BAD_GATEWAY;
|
|
156
|
+
res.status(proxyStatus).send({
|
|
157
|
+
status: 'error',
|
|
158
|
+
context: {
|
|
159
|
+
portalId: [`${accountId}`],
|
|
160
|
+
httpMethod: [method],
|
|
161
|
+
requestUri: [requestUri],
|
|
162
|
+
correlationId: [correlationId],
|
|
163
|
+
appServerStatusCode: [`${status}`],
|
|
164
|
+
},
|
|
165
|
+
message: proxyMessage,
|
|
166
|
+
responseBody,
|
|
167
|
+
errors: [
|
|
168
|
+
{
|
|
169
|
+
message: `Unacceptable HTTP status ${status} calling ${requestUri}`,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
category: proxyCategory,
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
res.status(status).send({
|
|
177
|
+
status: 'error',
|
|
178
|
+
message,
|
|
179
|
+
category,
|
|
180
|
+
context: {
|
|
181
|
+
correlationId: [correlationId],
|
|
182
|
+
httpMethod: [method],
|
|
183
|
+
appServerStatusCode: [`${status}`],
|
|
184
|
+
portalId: [`${accountId}`],
|
|
185
|
+
requestUri: [requestUri],
|
|
186
|
+
},
|
|
187
|
+
responseBody,
|
|
188
|
+
errors: responseBody ? [responseBody] : undefined,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
logger.info(`Request completed, correlationId=${correlationId}`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return app;
|
|
196
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ServiceConfiguration, PrivateAppUserToken } from '../types.ts';
|
|
2
|
+
export interface GetPrivateAppUserTokenOptions {
|
|
3
|
+
scopeGroups?: string[];
|
|
4
|
+
privateAppToken?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class PrivateAppUserTokenManager {
|
|
7
|
+
accountId: number;
|
|
8
|
+
private tokenMap;
|
|
9
|
+
private enabled;
|
|
10
|
+
private logger;
|
|
11
|
+
constructor(accountId: number, logger: ServiceConfiguration['logger']);
|
|
12
|
+
init(): Promise<void>;
|
|
13
|
+
isEnabled(): boolean;
|
|
14
|
+
cleanup(): void;
|
|
15
|
+
getPrivateAppUserToken(appId: number, { scopeGroups, privateAppToken }?: GetPrivateAppUserTokenOptions): Promise<PrivateAppUserToken | undefined>;
|
|
16
|
+
private cacheToken;
|
|
17
|
+
private validateToken;
|
|
18
|
+
private createNewToken;
|
|
19
|
+
private updateToken;
|
|
20
|
+
private getExistingToken;
|
|
21
|
+
static doesUserTokenContainAppTokenScopes(privateAppUserToken: PrivateAppUserToken): boolean;
|
|
22
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { scopesOnAccessToken } from '@hubspot/local-dev-lib/personalAccessKey';
|
|
2
|
+
import { isHubSpotHttpError } from '@hubspot/local-dev-lib/errors/index';
|
|
3
|
+
import { fetchPrivateAppUserToken, createPrivateAppUserToken, updatePrivateAppUserToken, } from "../api/privateAppUserToken.js";
|
|
4
|
+
import { USER_TOKEN_READ, USER_TOKEN_WRITE, TOKEN_TIME_TO_LIVE, TOKEN_REFRESH_THRESHOLD, } from "./constants.js";
|
|
5
|
+
import { generateCahedTokeMessage, generateMissingScopesMessage, generateTokensEnableMessage, generateTokensNotEnabledMessage, generateTokenCachedMessage, generateTokenOpErrorMessage, generateCreateTokenMessage, generateTokenRefreshMessage, generateMissingScopesMoreContextMessage, } from "./messages.js";
|
|
6
|
+
import { reportError } from "../errorReporter.js";
|
|
7
|
+
const MINUTES_TO_MS = 60000;
|
|
8
|
+
function getTokenExpirationDate() {
|
|
9
|
+
return new Date(Date.now() + TOKEN_TIME_TO_LIVE * MINUTES_TO_MS).toISOString();
|
|
10
|
+
}
|
|
11
|
+
function getExpDateWithThreshold(expiresAt) {
|
|
12
|
+
/*
|
|
13
|
+
* Subtracting the threshold from the expiration date to ensure
|
|
14
|
+
* that the token is still valid when it's used. If we waint until
|
|
15
|
+
* the expiration date, we could fire a request with an expired token.
|
|
16
|
+
*/
|
|
17
|
+
return new Date(new Date(expiresAt).getTime() - TOKEN_REFRESH_THRESHOLD * MINUTES_TO_MS);
|
|
18
|
+
}
|
|
19
|
+
export class PrivateAppUserTokenManager {
|
|
20
|
+
accountId;
|
|
21
|
+
tokenMap;
|
|
22
|
+
enabled;
|
|
23
|
+
logger;
|
|
24
|
+
constructor(accountId, logger) {
|
|
25
|
+
this.accountId = accountId;
|
|
26
|
+
this.tokenMap = new Map();
|
|
27
|
+
this.enabled = false;
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
}
|
|
30
|
+
async init() {
|
|
31
|
+
const scopeGroups = new Set(await scopesOnAccessToken(this.accountId));
|
|
32
|
+
if (scopeGroups.has(USER_TOKEN_READ) && scopeGroups.has(USER_TOKEN_WRITE)) {
|
|
33
|
+
this.logger.info(generateTokensEnableMessage(this.accountId));
|
|
34
|
+
this.enabled = true;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.logger.info(generateMissingScopesMessage(this.accountId));
|
|
38
|
+
this.logger.debug(generateMissingScopesMoreContextMessage(this.accountId));
|
|
39
|
+
}
|
|
40
|
+
isEnabled() {
|
|
41
|
+
return this.enabled;
|
|
42
|
+
}
|
|
43
|
+
cleanup() {
|
|
44
|
+
this.tokenMap.clear();
|
|
45
|
+
}
|
|
46
|
+
async getPrivateAppUserToken(appId, { scopeGroups = [], privateAppToken } = {}) {
|
|
47
|
+
if (!this.isEnabled()) {
|
|
48
|
+
this.logger.debug(generateTokensNotEnabledMessage(this.accountId, appId));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const tokenInCache = this.tokenMap.get(appId);
|
|
53
|
+
// we have a token in cache and it's valid
|
|
54
|
+
if (tokenInCache && this.validateToken(tokenInCache.token, scopeGroups)) {
|
|
55
|
+
this.logger.debug(generateCahedTokeMessage(appId));
|
|
56
|
+
return tokenInCache.token;
|
|
57
|
+
}
|
|
58
|
+
let token = await this.getExistingToken(appId);
|
|
59
|
+
// token exists but it's not valid. Refresh it
|
|
60
|
+
if (token && !this.validateToken(token, scopeGroups)) {
|
|
61
|
+
token = await this.updateToken({
|
|
62
|
+
appId,
|
|
63
|
+
scopeGroups,
|
|
64
|
+
privateAppToken,
|
|
65
|
+
userTokenKey: token.userTokenKey,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// token doesn't exist. Create a new one.
|
|
69
|
+
else if (token === null) {
|
|
70
|
+
token = await this.createNewToken(appId, scopeGroups, privateAppToken);
|
|
71
|
+
}
|
|
72
|
+
this.cacheToken(appId, token, scopeGroups);
|
|
73
|
+
return token;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
reportError(err, {
|
|
77
|
+
operation: 'get_private_app_user_token',
|
|
78
|
+
appId,
|
|
79
|
+
accountId: this.accountId,
|
|
80
|
+
errorType: 'token_manager_error',
|
|
81
|
+
});
|
|
82
|
+
let messageDetail = 'Unknown error';
|
|
83
|
+
if (err instanceof Error) {
|
|
84
|
+
messageDetail = err.message;
|
|
85
|
+
}
|
|
86
|
+
throw new Error(generateTokenOpErrorMessage({
|
|
87
|
+
operation: 'get',
|
|
88
|
+
appId,
|
|
89
|
+
accountId: this.accountId,
|
|
90
|
+
messageDetail,
|
|
91
|
+
}), { cause: err });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
cacheToken(appId, token, requestedScopeGroups) {
|
|
95
|
+
if (!token) {
|
|
96
|
+
throw new Error(generateTokenOpErrorMessage({
|
|
97
|
+
appId,
|
|
98
|
+
accountId: this.accountId,
|
|
99
|
+
operation: 'refresh',
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
const cachedValue = {
|
|
103
|
+
token,
|
|
104
|
+
requestedScopeGroups,
|
|
105
|
+
};
|
|
106
|
+
this.tokenMap.set(appId, cachedValue);
|
|
107
|
+
this.logger.debug(generateTokenCachedMessage(appId, token.expiresAt));
|
|
108
|
+
}
|
|
109
|
+
validateToken(token, scopeGroups) {
|
|
110
|
+
if (!token) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const isTokenActive = new Date() < getExpDateWithThreshold(token.expiresAt);
|
|
114
|
+
const hasAllScopes = Array.isArray(scopeGroups) &&
|
|
115
|
+
scopeGroups.every((scopeGroup) => token.scopeGroups.includes(scopeGroup));
|
|
116
|
+
return isTokenActive && hasAllScopes;
|
|
117
|
+
}
|
|
118
|
+
async createNewToken(appId, scopeGroups, privateAppToken) {
|
|
119
|
+
this.logger.debug(generateCreateTokenMessage(appId));
|
|
120
|
+
const response = await createPrivateAppUserToken({
|
|
121
|
+
appId,
|
|
122
|
+
scopeGroups,
|
|
123
|
+
privateAppToken,
|
|
124
|
+
accountId: this.accountId,
|
|
125
|
+
expiresAt: getTokenExpirationDate(),
|
|
126
|
+
});
|
|
127
|
+
if (response.status === 200) {
|
|
128
|
+
return response.data;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(generateTokenOpErrorMessage({
|
|
131
|
+
appId,
|
|
132
|
+
operation: 'create',
|
|
133
|
+
accountId: this.accountId,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
async updateToken({ appId, userTokenKey, scopeGroups, privateAppToken, }) {
|
|
137
|
+
this.logger.debug(generateTokenRefreshMessage(appId));
|
|
138
|
+
const response = await updatePrivateAppUserToken({
|
|
139
|
+
appId,
|
|
140
|
+
userTokenKey,
|
|
141
|
+
scopeGroups,
|
|
142
|
+
privateAppToken,
|
|
143
|
+
accountId: this.accountId,
|
|
144
|
+
expiresAt: getTokenExpirationDate(),
|
|
145
|
+
});
|
|
146
|
+
if (response.status === 200) {
|
|
147
|
+
return response.data;
|
|
148
|
+
}
|
|
149
|
+
throw new Error(generateTokenOpErrorMessage({
|
|
150
|
+
appId,
|
|
151
|
+
accountId: this.accountId,
|
|
152
|
+
operation: 'refresh',
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
async getExistingToken(appId) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetchPrivateAppUserToken({
|
|
158
|
+
accountId: this.accountId,
|
|
159
|
+
appId,
|
|
160
|
+
});
|
|
161
|
+
if (response.status === 200) {
|
|
162
|
+
return response.data;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (isHubSpotHttpError(err) && err?.status === 404) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
static doesUserTokenContainAppTokenScopes(privateAppUserToken) {
|
|
174
|
+
const privateAppToken = privateAppUserToken.privateAppTokenInfo;
|
|
175
|
+
// if the private app token is not present, we can't compare the scopes
|
|
176
|
+
if (!privateAppToken)
|
|
177
|
+
return false;
|
|
178
|
+
const privateAppTokenScopes = privateAppToken.scopeGroups ?? [];
|
|
179
|
+
const privateAppUserTokenScopes = privateAppUserToken.scopeGroups ?? [];
|
|
180
|
+
const areBothEmpty = privateAppUserTokenScopes.length === 0 &&
|
|
181
|
+
privateAppTokenScopes.length === 0;
|
|
182
|
+
return (areBothEmpty ||
|
|
183
|
+
privateAppTokenScopes.every((scope) => privateAppUserTokenScopes.includes(scope)));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function generateMissingScopesMessage(accountId: number): string;
|
|
2
|
+
export declare function generateTokensEnableMessage(accountId: number): string;
|
|
3
|
+
export declare function generateMissingScopesMoreContextMessage(accountId: number): string;
|
|
4
|
+
export declare function generateTokensNotEnabledMessage(accountId: number, appId: number): string;
|
|
5
|
+
export declare function generateCahedTokeMessage(appId: number): string;
|
|
6
|
+
export declare function generateTokenCachedMessage(appId: number, expiresAt: string): string;
|
|
7
|
+
export declare function generateCreateTokenMessage(appId: number): string;
|
|
8
|
+
export declare function generateTokenOpErrorMessage({ appId, accountId, messageDetail, operation, }: {
|
|
9
|
+
appId: number;
|
|
10
|
+
accountId: number;
|
|
11
|
+
messageDetail?: string;
|
|
12
|
+
operation?: 'create' | 'get' | 'refresh';
|
|
13
|
+
}): string;
|
|
14
|
+
export declare function generateTokenRefreshMessage(appId: number): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function generateMissingScopesMessage(accountId) {
|
|
2
|
+
return `Your account is missing the "App functions" scope, so some new features won’t work. To fix this, regenerate the Personal Access Key for account: ${accountId}`;
|
|
3
|
+
}
|
|
4
|
+
export function generateTokensEnableMessage(accountId) {
|
|
5
|
+
return `Private app user tokens are enabled for accountId: ${accountId}`;
|
|
6
|
+
}
|
|
7
|
+
export function generateMissingScopesMoreContextMessage(accountId) {
|
|
8
|
+
return `
|
|
9
|
+
Heads up! Private app user tokens are now supported for serverless functions, but it looks like the account (ID: ${accountId}) is missing the required "App functions" scope.
|
|
10
|
+
|
|
11
|
+
To fix this:
|
|
12
|
+
1. Deactivate the existing Personal Access Key for this account.
|
|
13
|
+
2. Generate a new Personal Access Key with the "App functions" scope enabled.
|
|
14
|
+
3. Run "hs auth" and reauthenticate the account (ID: ${accountId}).
|
|
15
|
+
|
|
16
|
+
Once updated, you won't need a "PRIVATE_APP_ACCESS_TOKEN" for local development.
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
export function generateTokensNotEnabledMessage(accountId, appId) {
|
|
20
|
+
return `Private app user tokens are not enabled for accountId ${accountId}, skipping call to fetch for appId ${appId}`;
|
|
21
|
+
}
|
|
22
|
+
export function generateCahedTokeMessage(appId) {
|
|
23
|
+
return `Using cached private app user token for appId ${appId}`;
|
|
24
|
+
}
|
|
25
|
+
export function generateTokenCachedMessage(appId, expiresAt) {
|
|
26
|
+
return `Token for appId ${appId} expiring at ${expiresAt} is in cache.`;
|
|
27
|
+
}
|
|
28
|
+
export function generateCreateTokenMessage(appId) {
|
|
29
|
+
return `Creating new private app user token for appId ${appId}`;
|
|
30
|
+
}
|
|
31
|
+
export function generateTokenOpErrorMessage({ appId, accountId, messageDetail = '', operation = 'create', }) {
|
|
32
|
+
return `Unable to ${operation} private app user token for appId ${appId} on accountId ${accountId}. ${messageDetail}`;
|
|
33
|
+
}
|
|
34
|
+
export function generateTokenRefreshMessage(appId) {
|
|
35
|
+
return `Refreshing private app user token for appId ${appId}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface SignV2Params {
|
|
2
|
+
method: string;
|
|
3
|
+
url: string;
|
|
4
|
+
requestBody: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
signature: '';
|
|
7
|
+
}
|
|
8
|
+
interface SignV3Params extends SignV2Params {
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
interface Sign {
|
|
12
|
+
v2: (options: SignV2Params) => string;
|
|
13
|
+
v3: (options: SignV3Params) => string;
|
|
14
|
+
}
|
|
15
|
+
export declare const sign: Sign;
|
|
16
|
+
interface SignatureHeaders {
|
|
17
|
+
'X-HubSpot-Signature': string;
|
|
18
|
+
'X-HubSpot-Signature-Version': 'v2';
|
|
19
|
+
'X-HubSpot-Request-Timestamp': number;
|
|
20
|
+
'X-HubSpot-Signature-v3': string;
|
|
21
|
+
}
|
|
22
|
+
type GetSignatureHeaders = (params: {
|
|
23
|
+
method: string;
|
|
24
|
+
url: string;
|
|
25
|
+
requestBody?: unknown;
|
|
26
|
+
}) => SignatureHeaders | undefined;
|
|
27
|
+
export declare const formatForSigning: (body: unknown) => string;
|
|
28
|
+
export declare const getSignatureHeaders: GetSignatureHeaders;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Signature } from '@hubspot/api-client';
|
|
2
|
+
// Only exported for testing
|
|
3
|
+
export const sign = {
|
|
4
|
+
v2: ({ method, ...options }) => Signature.getSignature(method, 'v2', options),
|
|
5
|
+
v3: ({ method, ...options }) => Signature.getSignature(method, 'v3', options),
|
|
6
|
+
};
|
|
7
|
+
export const formatForSigning = (body) => {
|
|
8
|
+
if (body === null) {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
if (typeof body === 'number' &&
|
|
12
|
+
(!Number.isFinite(body) || Number.isNaN(body))) {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
// Anything that stringifies to undefined will be an empty string
|
|
16
|
+
return JSON.stringify(body) || '';
|
|
17
|
+
};
|
|
18
|
+
// This is only used for signing local dev proxy requests
|
|
19
|
+
export const getSignatureHeaders = ({ method, url, requestBody, }) => {
|
|
20
|
+
const clientSecret = process.env.CLIENT_SECRET;
|
|
21
|
+
if (!clientSecret) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const timestamp = new Date().getTime();
|
|
25
|
+
const signingProps = {
|
|
26
|
+
method,
|
|
27
|
+
url,
|
|
28
|
+
requestBody: formatForSigning(requestBody),
|
|
29
|
+
clientSecret,
|
|
30
|
+
/**
|
|
31
|
+
* I'm not sure why the signing function requires this
|
|
32
|
+
*
|
|
33
|
+
* https://github.com/HubSpot/hubspot-api-nodejs/blob/bfaa106af7199adbce5f766b1eb35a73f3c72499/src/utils/ISignatureOptions.ts
|
|
34
|
+
*
|
|
35
|
+
* But it doesn't use it for signing
|
|
36
|
+
*
|
|
37
|
+
* https://github.com/HubSpot/hubspot-api-nodejs/blob/bfaa106af7199adbce5f766b1eb35a73f3c72499/src/utils/signature.ts#L19-L32
|
|
38
|
+
*
|
|
39
|
+
*/
|
|
40
|
+
signature: '',
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
'X-HubSpot-Signature': sign.v2(signingProps),
|
|
44
|
+
'X-HubSpot-Signature-Version': 'v2',
|
|
45
|
+
'X-HubSpot-Request-Timestamp': timestamp,
|
|
46
|
+
'X-HubSpot-Signature-v3': sign.v3({
|
|
47
|
+
...signingProps,
|
|
48
|
+
timestamp,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
};
|