ti2 1.0.124 → 1.0.125

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/README.md CHANGED
@@ -94,6 +94,22 @@ The product cache refresh interval defaults to seven days when no TTR is set.
94
94
  Any explicit `ttlForProducts` or plugin `cacheSettings.bookingsProductSearch.ttr`
95
95
  value is honored, including `86400` for a one-day interval.
96
96
 
97
+ ### Optional catalog lifecycle service
98
+
99
+ Ti2 synchronously asks Filematch to update catalog lifecycle state when an
100
+ integration is removed or reactivated. The call reuses
101
+ `ti2_events2url_eventsURL` and `ti2_events2url_authorization`; Filematch then
102
+ forwards the request to Pyfilematch. Without the events URL, Ti2 cleans its own
103
+ credentials and schedules without making an external catalog request.
104
+ `INTEGRATION_LIFECYCLE_TIMEOUT_MS` controls the request timeout and defaults to
105
+ 30 seconds. A save left in `provisioning` blocks deletion until
106
+ it is older than `INTEGRATION_PROVISIONING_TIMEOUT_MS` (five minutes by
107
+ default); deletion then advances the generation before removing local and
108
+ catalog state. Catalog lifecycle consumers must accept newer generations so a
109
+ failed activation followed by deletion can move from generation N to N+1. The
110
+ timeout is a provisioning lease: saves refresh it before catalog activation and
111
+ must still own their generation when marking activation complete.
112
+
97
113
  ## Contributing
98
114
 
99
115
  Contributions are welcome and ecouraged.
package/api.yml CHANGED
@@ -101,6 +101,53 @@ components:
101
101
  properties:
102
102
  value:
103
103
  type: string
104
+ IntegrationCleanup:
105
+ type: object
106
+ required:
107
+ - userId
108
+ - integrationId
109
+ - hint
110
+ - generation
111
+ - requestId
112
+ - status
113
+ properties:
114
+ userId:
115
+ type: string
116
+ integrationId:
117
+ type: string
118
+ hint:
119
+ type: string
120
+ generation:
121
+ type: integer
122
+ minimum: 1
123
+ requestId:
124
+ type: string
125
+ requestedAt:
126
+ type: string
127
+ format: date-time
128
+ retryCount:
129
+ type: integer
130
+ minimum: 0
131
+ status:
132
+ type: string
133
+ enum:
134
+ - deleting
135
+ - failed
136
+ - external_cleanup
137
+ - complete
138
+ artifacts:
139
+ type: object
140
+ additionalProperties: true
141
+ IntegrationCleanupResponse:
142
+ type: object
143
+ required:
144
+ - message
145
+ - cleanup
146
+ properties:
147
+ message:
148
+ type: string
149
+ cleanup:
150
+ $ref: '#/components/schemas/IntegrationCleanup'
104
151
  CreateAppTokenResponse:
105
152
  type: object
106
153
  required:
@@ -186,6 +233,9 @@ components:
186
233
  type: string
187
234
  description: An identifier for this token, it should NOT be the token itself but a short safe version of it
188
235
  example: 'expediaTestLocation-23'
236
+ deferCompletion:
237
+ type: boolean
238
+ description: Keep re-creation blocked until the caller finalizes external cleanup
189
239
  UserAppValidate:
190
240
  description: A user + App configuration to validate
191
241
  type: object
@@ -254,6 +304,13 @@ components:
254
304
  type: string
255
305
  updatedAt:
256
306
  type: string
307
+ cleanupStatus:
308
+ type: string
309
+ description: Present when removal is incomplete and may be retried.
310
+ enum:
311
+ - deleting
312
+ - external_cleanup
313
+ - failed
257
314
  appRequest:
258
315
  type: object
259
316
  properties:
@@ -982,7 +1039,7 @@ paths:
982
1039
  content:
983
1040
  application/json:
984
1041
  schema:
985
- $ref: '#/components/schemas/Message'
1042
+ $ref: '#/components/schemas/IntegrationCleanupResponse'
986
1043
  put:
987
1044
  tags:
988
1045
  - app
@@ -1020,6 +1077,56 @@ paths:
1020
1077
  application/json:
1021
1078
  schema:
1022
1079
  $ref: '#/components/schemas/Message'
1080
+ /{app}/{userId}/cleanup/finalize:
1081
+ post:
1082
+ tags:
1083
+ - app
1084
+ - user
1085
+ summary: Finalize an integration cleanup after external artifacts are removed
1086
+ security:
1087
+ - bearerAuth: ['admin', 'app', 'user']
1088
+ operationId: finalizeIntegrationCleanup
1089
+ parameters:
1090
+ - name: app
1091
+ in: path
1092
+ required: true
1093
+ schema:
1094
+ type: string
1095
+ - name: userId
1096
+ in: path
1097
+ required: true
1098
+ schema:
1099
+ type: string
1100
+ requestBody:
1101
+ required: true
1102
+ content:
1103
+ application/json:
1104
+ schema:
1105
+ type: object
1106
+ required:
1107
+ - tokenHint
1108
+ - generation
1109
+ - requestId
1110
+ properties:
1111
+ tokenHint:
1112
+ type: string
1113
+ generation:
1114
+ type: integer
1115
+ minimum: 1
1116
+ requestId:
1117
+ type: string
1118
+ minLength: 1
1119
+ maxLength: 64
1120
+ artifacts:
1121
+ type: object
1122
+ additionalProperties: true
1123
+ responses:
1124
+ 200:
1125
+ description: success
1126
+ content:
1127
+ application/json:
1128
+ schema:
1129
+ $ref: '#/components/schemas/IntegrationCleanupResponse'
1023
1130
  /{app}/{userId}/{hint}/token:
1024
1131
  get:
1025
1132
  tags:
@@ -38,6 +38,7 @@ const appCheck = async req => {
38
38
  return 'Invalid api key';
39
39
  }
40
40
  req.appRecord = appRecord.dataValues;
41
+ req.auth = { subject: `app:${req.pathParams.app}` };
41
42
  return undefined;
42
43
  };
43
44
 
@@ -53,6 +54,7 @@ const app = async (req, res, next) => {
53
54
  const adminCheck = async req => {
54
55
  const token = getToken({ req });
55
56
  if (token !== adminKey) return 'Invalid admin key';
57
+ req.auth = { subject: 'admin' };
56
58
  return undefined;
57
59
  };
58
60
 
@@ -78,6 +80,7 @@ const userCheck = async req => {
78
80
  if (req.params.userId && userId !== req.params.userId) {
79
81
  return 'Non matching user id';
80
82
  }
83
+ req.auth = { subject: `user:${userId}` };
81
84
  return undefined;
82
85
  };
83
86
 
@@ -1,9 +1,11 @@
1
1
  /* globals beforeAll describe it expect */
2
2
  const chance = require('chance').Chance();
3
+ const crypto = require('crypto');
3
4
  const jwt = require('jwt-promise');
4
5
  const R = require('ramda');
5
6
 
6
7
  const slugify = require('../../test/slugify');
8
+ const { queue } = require('../../worker/queue');
7
9
  let appController = require('../app');
8
10
 
9
11
  const { env: { adminKey, jwtSecret } } = process;
@@ -13,6 +15,7 @@ describe('app', () => {
13
15
  const appName = slugify(
14
16
  chance.company(),
15
17
  );
18
+ const otherAppName = slugify(chance.company());
16
19
  const newApp = {
17
20
  name: appName,
18
21
  packageName: `ti2-${appName}`,
@@ -21,6 +24,8 @@ describe('app', () => {
21
24
  let doApiPost;
22
25
  let doApiGet;
23
26
  let doApiPut;
27
+ let db;
28
+ let plugins;
24
29
  let appKey;
25
30
  const userId = chance.guid();
26
31
  const apiKey = chance.guid();
@@ -34,8 +39,10 @@ describe('app', () => {
34
39
  };
35
40
  let encodedKey;
36
41
  beforeAll(async () => {
37
- ({ doApiPost, doApiGet, doApiPut } = await testUtils({
38
- plugins: [appName],
42
+ ({
43
+ doApiPost, doApiGet, doApiPut, plugins, sqldb: db,
44
+ } = await testUtils({
45
+ plugins: [appName, otherAppName],
39
46
  }));
40
47
  appController = appController([appName]);
41
48
  });
@@ -69,6 +76,12 @@ describe('app', () => {
69
76
  },
70
77
  });
71
78
  expect(parseInt(value, 10)).toBeGreaterThan(0);
79
+ expect(await db.models.CronJobs.count({
80
+ where: { userId, hint: apiKey.split('-')[0], pluginName: appName },
81
+ })).toBeGreaterThan(0);
82
+ expect(await db.models.CronJobs.count({
83
+ where: { userId, hint: apiKey.split('-')[0], pluginName: otherAppName },
84
+ })).toBe(0);
72
85
  });
73
86
  it('should be able to test a user token for the app', async () => {
74
87
  const { valid } = await doApiPost({
@@ -96,7 +109,39 @@ describe('app', () => {
96
109
  cron: '0 9 * * *',
97
110
  });
98
111
  });
99
- it.todo('if a new version of the plugin has a different set of scheduled tasks, they should be re-synced');
112
+ it('replaces the repeatable definition when a cron pattern changes', async () => {
113
+ const plugin = plugins.find(({ name }) => name === appName);
114
+ plugin.jobs[0].cron = '0 10 * * *';
115
+
116
+ await doApiPost({
117
+ url: `/${appName}/${userId}`,
118
+ token: appKey,
119
+ payload: {
120
+ tokenHint: apiKey.split('-')[0],
121
+ token,
122
+ },
123
+ });
124
+
125
+ const jobs = R.path(
126
+ ['jobs'],
127
+ await appController.getAppScheduledJobs({ integrationId: appName, userId }),
128
+ );
129
+ expect(R.head(R.project(['pluginJobId', 'cron'], jobs))).toEqual({
130
+ pluginJobId: 'dailyReport',
131
+ cron: '0 10 * * *',
132
+ });
133
+ const repeatId = crypto.createHash('sha256').update(JSON.stringify({
134
+ pluginName: appName,
135
+ pluginJobId: 'dailyReport',
136
+ userId,
137
+ hint: apiKey.split('-')[0],
138
+ })).digest('hex');
139
+ const repeatDefinitions = (await queue.getRepeatableJobs())
140
+ .filter(({ id }) => id === repeatId);
141
+ expect(repeatDefinitions).toEqual([
142
+ expect.objectContaining({ cron: '0 10 * * *' }),
143
+ ]);
144
+ });
100
145
  let jobId;
101
146
  it('should be able to run a job', async () => {
102
147
  let status;
@@ -97,11 +97,25 @@ describe('user', () => {
97
97
  expect(template).toBeTruthy();
98
98
  expect(R.path(['apiKey', 'regExp', 'source'], template)).toBeTruthy();
99
99
  });
100
+ it('returns 404 when deleting a hint with no credential or lifecycle', async () => {
101
+ const unknownHint = chance.word({ length: 12 });
102
+ await doApiDelete({
103
+ url: `/${appName}/${userId}`,
104
+ token: userKey,
105
+ payload: { tokenHint: unknownHint },
106
+ expectStatusCode: 404,
107
+ });
108
+
109
+ expect(await db.models.IntegrationLifecycle.count({
110
+ where: { userId, integrationId: appName, hint: unknownHint },
111
+ })).toBe(0);
112
+ });
100
113
  it('should be able to delete a user/app key', async () => {
114
+ const deletedHint = apiKey.split('-')[0];
101
115
  await doApiDelete({
102
116
  url: `/${appName}/${userId}`,
103
117
  token: userKey,
104
- payload: { tokenHint: apiKey.split('-')[0] },
118
+ payload: { tokenHint: deletedHint },
105
119
  });
106
120
  const { userAppKeys } = await doApiGet({
107
121
  url: `/user/${userId}/apps`,
@@ -116,6 +130,14 @@ describe('user', () => {
116
130
  }),
117
131
  ]),
118
132
  );
133
+ const lifecycle = await db.models.IntegrationLifecycle.findOne({
134
+ where: {
135
+ userId,
136
+ integrationId: appName,
137
+ hint: deletedHint,
138
+ },
139
+ });
140
+ expect(lifecycle.requestedBy).toBe(`user:${userId}`);
119
141
  });
120
142
  it('should be able to create a user/app integration', async () => {
121
143
  // set up new token
@@ -145,6 +167,70 @@ describe('user', () => {
145
167
  ]),
146
168
  );
147
169
  });
170
+ it('reports deferred deletion as pending until tokenHint finalization', async () => {
171
+ const deferredHint = chance.word({ length: 12 });
172
+ await doApiPost({
173
+ url: `/${appName}/${userId}`,
174
+ token: userKey,
175
+ payload: { tokenHint: deferredHint, token },
176
+ });
177
+
178
+ const deletion = await doApiDelete({
179
+ url: `/${appName}/${userId}`,
180
+ token: userKey,
181
+ payload: { tokenHint: deferredHint, deferCompletion: true },
182
+ });
183
+ expect(deletion).toEqual(expect.objectContaining({
184
+ message: 'Integration cleanup is awaiting external finalization.',
185
+ cleanup: expect.objectContaining({ status: 'external_cleanup' }),
186
+ }));
187
+
188
+ const pendingList = await doApiGet({
189
+ url: `/user/${userId}/apps`,
190
+ token: userKey,
191
+ });
192
+ expect(pendingList.userAppKeys).toEqual(expect.arrayContaining([
193
+ expect.objectContaining({
194
+ integrationId: appName,
195
+ userId,
196
+ hint: deferredHint,
197
+ cleanupStatus: 'external_cleanup',
198
+ }),
199
+ ]));
200
+
201
+ await doApiPost({
202
+ url: `/${appName}/${userId}/cleanup/finalize`,
203
+ token: userKey,
204
+ payload: {
205
+ tokenHint: deferredHint,
206
+ generation: deletion.cleanup.generation,
207
+ },
208
+ expectStatusCode: 400,
209
+ });
210
+
211
+ const finalized = await doApiPost({
212
+ url: `/${appName}/${userId}/cleanup/finalize`,
213
+ token: userKey,
214
+ payload: {
215
+ tokenHint: deferredHint,
216
+ generation: deletion.cleanup.generation,
217
+ requestId: deletion.cleanup.requestId,
218
+ artifacts: { test: true },
219
+ },
220
+ });
221
+ expect(finalized.cleanup.status).toBe('complete');
222
+
223
+ const completedList = await doApiGet({
224
+ url: `/user/${userId}/apps`,
225
+ token: userKey,
226
+ });
227
+ expect(completedList.userAppKeys).toEqual(expect.not.arrayContaining([
228
+ expect.objectContaining({
229
+ integrationId: appName,
230
+ hint: deferredHint,
231
+ }),
232
+ ]));
233
+ });
148
234
  it('reports post-save cron setup failure while preserving the committed credential', async () => {
149
235
  const postCommitUserId = chance.guid();
150
236
  const postCommitUserKey = (await doApiPost({
@@ -5,9 +5,11 @@ const { Umzug, SequelizeStorage } = require('umzug');
5
5
  const path = require('path');
6
6
  const Sequelize = require('sequelize');
7
7
  const fs = require('fs'); // Changed to synchronous fs for initial load
8
+ const crypto = require('crypto');
8
9
  const yaml = require('js-yaml');
9
10
  const bb = require('bluebird');
10
11
  const R = require('ramda');
12
+ const integrationLifecycle = require('../services/integrationLifecycle');
11
13
 
12
14
  // Load OpenAPI schema
13
15
  let openApiSchema = null;
@@ -102,6 +104,7 @@ const createAppToken = async (req, res, next) => {
102
104
  },
103
105
  } = req;
104
106
  let transaction;
107
+ let lifecycle;
105
108
  try {
106
109
  const payload = {
107
110
  integrationId,
@@ -117,6 +120,12 @@ const createAppToken = async (req, res, next) => {
117
120
  transaction,
118
121
  lock: transaction.LOCK.UPDATE,
119
122
  });
123
+ lifecycle = await integrationLifecycle.prepareActivation({
124
+ userId,
125
+ integrationId,
126
+ hint,
127
+ transaction,
128
+ });
120
129
  const integrationCount = await sqldb.UserAppKey.count({
121
130
  where: { userId },
122
131
  transaction,
@@ -146,7 +155,8 @@ const createAppToken = async (req, res, next) => {
146
155
  // Credential persistence is already committed, but cron provisioning must
147
156
  // remain caller-visible so missing scheduled functionality is not silent.
148
157
  try {
149
- await bb.each(req.app.plugins, async plugin => {
158
+ const integrationPlugins = req.app.plugins.filter(plugin => plugin.name === integrationId);
159
+ await bb.each(integrationPlugins, async plugin => {
150
160
  if (!Array.isArray(plugin.jobs)) return;
151
161
  const validJobs = plugin.jobs.filter(job => Boolean(job.cron) && Boolean(job.method));
152
162
  await bb.each(validJobs, async job => {
@@ -171,22 +181,25 @@ const createAppToken = async (req, res, next) => {
171
181
  },
172
182
  } : {}),
173
183
  ...(job.params || {}),
184
+ jobId: crypto.createHash('sha256').update(JSON.stringify({
185
+ pluginName: plugin.name,
186
+ pluginJobId: job.method,
187
+ userId,
188
+ hint,
189
+ })).digest('hex'),
174
190
  removeOnComplete: false,
175
191
  };
176
192
  if (existing) {
177
- const bullJob = await queue.getJob(job.bullJobId);
178
- if (!bullJob) {
193
+ if (existing.cron !== job.cron) {
194
+ await removeJob(existing.bullJobId);
179
195
  const rawBullJobId = await addJob(jobPayload, jobParams);
180
196
  existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
181
197
  && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
198
+ existing.cron = job.cron;
182
199
  await existing.save();
183
200
  } else {
184
- const bullCron = R.path(
185
- ['opts', 'repeat', 'cron'],
186
- await queue.getJob(existing.bullJobId),
187
- );
188
- if (bullCron !== job.cron) {
189
- await queue.removeJobs(existing.bullJobId);
201
+ const bullJob = await queue.getJob(existing.bullJobId);
202
+ if (!bullJob) {
190
203
  const rawBullJobId = await addJob(jobPayload, jobParams);
191
204
  existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
192
205
  && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
@@ -206,6 +219,12 @@ const createAppToken = async (req, res, next) => {
206
219
  throw cronErr;
207
220
  }
208
221
 
222
+ await integrationLifecycle.touchActivation(lifecycle);
223
+ if (R.pathOr(false, ['artifacts', 'requiresCatalogActivation'], lifecycle)) {
224
+ await integrationLifecycle.activateCatalog(lifecycle);
225
+ }
226
+ await integrationLifecycle.completeActivation(lifecycle);
227
+
209
228
  return res.json(responsePayload);
210
229
  } catch (err) {
211
230
  if (transaction && !transaction.finished) {
@@ -215,6 +234,13 @@ const createAppToken = async (req, res, next) => {
215
234
  console.error('Unable to roll back integration credential transaction', rollbackErr);
216
235
  }
217
236
  }
237
+ if (lifecycle && (!transaction || transaction.finished)) {
238
+ try {
239
+ await integrationLifecycle.failActivation(lifecycle, err);
240
+ } catch (lifecycleErr) {
241
+ console.error('Unable to record integration activation failure', lifecycleErr);
242
+ }
243
+ }
218
244
  return next(err);
219
245
  }
220
246
  };
@@ -250,24 +276,69 @@ const deleteAppToken = async (req, res, next) => {
250
276
  const {
251
277
  body: {
252
278
  tokenHint: hint,
279
+ deferCompletion = false,
253
280
  },
254
281
  params: {
255
282
  app: integrationId,
256
283
  userId,
257
284
  },
258
285
  } = req;
259
- // check if the user exists
260
- const userRecord = await sqldb.User.findOne({ where: { userId } });
261
- if (!userRecord) return next({ status: 404, message: 'User does not exists' });
262
- const retVal = await sqldb.UserAppKey.destroy({
263
- where: {
264
- integrationId,
286
+ try {
287
+ if (typeof hint !== 'string' || hint.length === 0 || hint.length > 256) {
288
+ return next({ status: 400, message: 'A valid tokenHint is required' });
289
+ }
290
+ const userRecord = await sqldb.User.findOne({ where: { userId } });
291
+ if (!userRecord) return next({ status: 404, message: 'User does not exists' });
292
+ const result = await integrationLifecycle.deleteIntegration({
265
293
  userId,
294
+ integrationId,
266
295
  hint,
296
+ requestedBy: R.pathOr('api', ['auth', 'subject'], req),
297
+ deferCompletion,
298
+ });
299
+ return res.json({
300
+ message: result.status === 'external_cleanup'
301
+ ? 'Integration cleanup is awaiting external finalization.'
302
+ : 'Integration cleanup complete.',
303
+ cleanup: result,
304
+ });
305
+ } catch (err) {
306
+ return next(err);
307
+ }
308
+ };
309
+
310
+ const finalizeIntegrationCleanup = async (req, res, next) => {
311
+ const {
312
+ body: {
313
+ tokenHint: hint,
314
+ generation,
315
+ requestId,
316
+ artifacts,
267
317
  },
268
- });
269
- if (retVal === 0) return next({ status: 404, message: 'Key not found' });
270
- return res.json({ message: `${retVal} erased` });
318
+ params: {
319
+ app: integrationId,
320
+ userId,
321
+ },
322
+ } = req;
323
+ try {
324
+ if (typeof hint !== 'string' || hint.length === 0 || hint.length > 256) {
325
+ return next({ status: 400, message: 'A valid tokenHint is required' });
326
+ }
327
+ if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 64) {
328
+ return next({ status: 400, message: 'A valid requestId is required' });
329
+ }
330
+ const cleanup = await integrationLifecycle.completeDeletion({
331
+ userId,
332
+ integrationId,
333
+ hint,
334
+ generation,
335
+ requestId,
336
+ externalArtifacts: artifacts,
337
+ });
338
+ return res.json({ message: 'Integration cleanup finalized.', cleanup });
339
+ } catch (err) {
340
+ return next(err);
341
+ }
271
342
  };
272
343
 
273
344
  const validateAppToken = plugins => async (req, res, next) => {
@@ -807,6 +878,7 @@ module.exports = plugins => ({
807
878
  getAppSettings,
808
879
  updateAppToken,
809
880
  deleteAppToken,
881
+ finalizeIntegrationCleanup,
810
882
  deleteAppSettings,
811
883
  getAppScheduledJobs,
812
884
  getJobStatus,
@@ -1,16 +1,44 @@
1
1
  // controllers/user.js
2
- const { omit } = require('ramda');
2
+ const { Op } = require('sequelize');
3
3
 
4
- const { UserAppKey } = require('../models');
4
+ const { IntegrationLifecycle, UserAppKey } = require('../models');
5
+
6
+ const pendingCleanupStatuses = ['deleting', 'external_cleanup', 'failed'];
7
+ const integrationKey = ({ integrationId, hint }) => JSON.stringify([integrationId, hint]);
5
8
 
6
9
  const userAppList = async (req, res, next) => {
7
10
  const { params: { userId } } = req;
8
11
  try {
9
- const userAppKeys = await UserAppKey.findAll({
10
- where: { userId },
11
- raw: true,
12
- attributes: ['integrationId', 'userId', 'hint', 'createdAt', 'updatedAt'],
13
- })
12
+ const [credentials, pendingCleanups] = await Promise.all([
13
+ UserAppKey.findAll({
14
+ where: { userId },
15
+ raw: true,
16
+ attributes: ['integrationId', 'userId', 'hint', 'createdAt', 'updatedAt'],
17
+ }),
18
+ IntegrationLifecycle.findAll({
19
+ where: {
20
+ userId,
21
+ status: { [Op.in]: pendingCleanupStatuses },
22
+ },
23
+ raw: true,
24
+ attributes: ['integrationId', 'userId', 'hint', 'status'],
25
+ }),
26
+ ]);
27
+ const cleanupByIntegration = new Map(
28
+ pendingCleanups.map(cleanup => [integrationKey(cleanup), cleanup]),
29
+ );
30
+ const userAppKeys = credentials.map(credential => {
31
+ const cleanup = cleanupByIntegration.get(integrationKey(credential));
32
+ if (!cleanup) return credential;
33
+ cleanupByIntegration.delete(integrationKey(credential));
34
+ return { ...credential, cleanupStatus: cleanup.status };
35
+ });
36
+ cleanupByIntegration.forEach(cleanup => userAppKeys.push({
37
+ integrationId: cleanup.integrationId,
38
+ userId: cleanup.userId,
39
+ hint: cleanup.hint,
40
+ cleanupStatus: cleanup.status,
41
+ }));
14
42
  return res.json({ userAppKeys });
15
43
  } catch (err) {
16
44
  return next(err);