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 +16 -0
- package/api.yml +108 -1
- package/auth/authHandler.js +3 -0
- package/controllers/__tests__/app.js +48 -3
- package/controllers/__tests__/user.js +87 -1
- package/controllers/app.js +90 -18
- package/controllers/user.js +35 -7
- package/migrations/20260914180000-create-integration-lifecycles.js +67 -0
- package/models/integrationlifecycle.js +44 -0
- package/package.json +1 -1
- package/services/__tests__/integrationLifecycle.db.js +251 -0
- package/services/__tests__/integrationLifecycle.js +563 -0
- package/services/integrationLifecycle.js +496 -0
- package/worker/__tests__/queue-remove.js +77 -0
- package/worker/queue.js +38 -49
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/
|
|
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:
|
package/auth/authHandler.js
CHANGED
|
@@ -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
|
-
({
|
|
38
|
-
plugins:
|
|
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
|
|
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:
|
|
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({
|
package/controllers/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
where: {
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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,
|
package/controllers/user.js
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
// controllers/user.js
|
|
2
|
-
const {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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);
|