@abtnode/blocklet-services 1.8.3 → 1.8.4

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/api/index.js CHANGED
@@ -30,7 +30,8 @@ const { init: initAuth } = require('./services/auth');
30
30
  const StaticService = require('./services/static');
31
31
  const createEnvRoutes = require('./routes/env');
32
32
  const createBlockletRoutes = require('./routes/blocklet');
33
- const createConnectRelayRoutes = require('./routes/connect-relay');
33
+ const createConnectRelayRoutes = require('./routes/connect/relay');
34
+ const createConnectSessionRoutes = require('./routes/connect/session');
34
35
  const createDnsResolver = require('./routes/dns-resolver');
35
36
  const checkRunning = require('./middlewares/check-running');
36
37
  const checkAdminPermission = require('./middlewares/check-admin-permission');
@@ -199,6 +200,7 @@ module.exports = function createServer(node, serverOptions = {}) {
199
200
  // API: auth
200
201
  createEnvRoutes.init(server, node, options);
201
202
  createBlockletRoutes.init(server, node, options);
203
+ createConnectSessionRoutes.init(server, node, options);
202
204
  createConnectRelayRoutes.init(server, node, options, wsRouter);
203
205
  authRoutes.attachDidAuthHandlers(server);
204
206
  authRoutes.createPassportRoutes.init(server, node, options);
@@ -0,0 +1,456 @@
1
+ // Holds shared logic for session-manager v1 and v2
2
+ const get = require('lodash/get');
3
+ const joinUrl = require('url-join');
4
+ const formatContext = require('@abtnode/util/lib/format-context');
5
+ const { extractUserAvatar } = require('@abtnode/util/lib/user-avatar');
6
+ const {
7
+ messages,
8
+ getUser,
9
+ getVCFromClaims,
10
+ validatePassportStatus,
11
+ getPassportStatusEndpoint,
12
+ } = require('@abtnode/auth/lib/auth');
13
+ const {
14
+ NODE_SERVICES,
15
+ ROLES,
16
+ WELLKNOWN_SERVICE_PATH_PREFIX,
17
+ VC_TYPE_GENERAL_PASSPORT,
18
+ VC_TYPE_NODE_PASSPORT,
19
+ WHO_CAN_ACCESS,
20
+ } = require('@abtnode/constant');
21
+ const {
22
+ validatePassport,
23
+ isUserPassportRevoked,
24
+ getRoleFromLocalPassport,
25
+ getRoleFromExternalPassport,
26
+ createUserPassport,
27
+ createPassportVC,
28
+ createPassport,
29
+ upsertToPassports,
30
+ } = require('@abtnode/auth/lib/passport');
31
+
32
+ const logger = require('@abtnode/logger')(require('../../../package.json').name);
33
+
34
+ const vcTypes = [VC_TYPE_GENERAL_PASSPORT, VC_TYPE_NODE_PASSPORT];
35
+
36
+ /**
37
+ * @returns {Array} config
38
+ * @returns {Boolean} config[0] is invited user only
39
+ * @returns {String} config[1] default role
40
+ * @returns {Boolean} config[2] issue passport
41
+ */
42
+ const isInvitedUserOnly = async (config, node, teamDid) => {
43
+ const count = await node.getUsersCount({ teamDid });
44
+
45
+ // issue owner passport for first login user
46
+ if (count === 0) {
47
+ return [false, ROLES.OWNER, true];
48
+ }
49
+
50
+ if ([WHO_CAN_ACCESS.OWNER, WHO_CAN_ACCESS.INVITED].includes(config.whoCanAccess)) {
51
+ return [true];
52
+ }
53
+
54
+ if ([WHO_CAN_ACCESS.ALL].includes(config.whoCanAccess)) {
55
+ return [false, ROLES.GUEST];
56
+ }
57
+
58
+ return [false, ROLES.GUEST];
59
+ };
60
+
61
+ module.exports = {
62
+ login: {
63
+ onConnect: async ({ node, request, userDid, locale, passportId = '' }) => {
64
+ const blocklet = await request.getBlocklet();
65
+ const config = await request.getServiceConfig(NODE_SERVICES.AUTH);
66
+ const { wallet, did: teamDid } = await request.getBlockletInfo();
67
+
68
+ const profileFields = get(config, 'profileFields');
69
+ const [invitedUserOnly] = await isInvitedUserOnly(config, node, teamDid);
70
+ const trustedPassports = (blocklet.trustedPassports || []).map((x) => x.issuerDid);
71
+ const trustedIssuers = [wallet.address, ...trustedPassports].filter(Boolean);
72
+
73
+ const claims = {
74
+ profile: {
75
+ type: 'profile',
76
+ description: messages.description[locale],
77
+ items: profileFields || ['fullName', 'avatar'],
78
+ },
79
+ verifiableCredential: {
80
+ type: 'verifiableCredential',
81
+ description: messages.requestPassport[locale],
82
+ item: vcTypes,
83
+ trustedIssuers,
84
+ optional: !invitedUserOnly,
85
+ },
86
+ };
87
+ if (passportId) {
88
+ claims.verifiableCredential.target = passportId;
89
+ }
90
+
91
+ const user = await node.getUser({ teamDid: blocklet.meta.did, user: { did: userDid } });
92
+ if (user) {
93
+ delete claims.profile;
94
+ }
95
+
96
+ return claims;
97
+ },
98
+
99
+ onApprove: async ({ node, request, locale, challenge, userDid, userPk, claims, baseUrl, createSessionToken }) => {
100
+ const blocklet = await request.getBlocklet();
101
+ const { wallet, name, passportColor, did: teamDid } = await request.getBlockletInfo();
102
+ const teamAppDid = wallet.address;
103
+
104
+ // check user approved
105
+ const user = await getUser(node, teamDid, userDid);
106
+ if (user && !user.approved) {
107
+ throw new Error(messages.notAllowed[locale]);
108
+ }
109
+
110
+ // Get passport
111
+ const trustedPassports = (blocklet.trustedPassports || []).map((x) => x.issuerDid);
112
+ const trustedIssuers = [teamAppDid, ...trustedPassports].filter(Boolean);
113
+ const { vc: inputVC } = await getVCFromClaims({
114
+ claims,
115
+ challenge,
116
+ trustedIssuers,
117
+ vcTypes,
118
+ locale,
119
+ });
120
+
121
+ let vc = inputVC;
122
+
123
+ const config = (await request.getServiceConfig(NODE_SERVICES.AUTH)) || {};
124
+ const [invitedUserOnly, defaultRole, issuePassport] = await isInvitedUserOnly(config, node, teamDid);
125
+
126
+ if (invitedUserOnly && !vc) {
127
+ throw new Error(messages.missingCredentialClaim[locale]);
128
+ }
129
+
130
+ // issue passport for the first login user in a invite-only team
131
+ if (issuePassport) {
132
+ logger.info('issue passport to user at the login workflow', { role: defaultRole });
133
+ const profile = claims.find((x) => x.type === 'profile');
134
+ vc = createPassportVC({
135
+ issuerName: name,
136
+ issuerWallet: wallet,
137
+ ownerDid: userDid,
138
+ passport: await createPassport({
139
+ name: defaultRole,
140
+ node,
141
+ teamDid,
142
+ locale,
143
+ endpoint: baseUrl,
144
+ }),
145
+ endpoint: getPassportStatusEndpoint({
146
+ baseUrl: joinUrl(baseUrl, WELLKNOWN_SERVICE_PATH_PREFIX),
147
+ userDid,
148
+ teamDid,
149
+ }),
150
+ ownerProfile: profile,
151
+ preferredColor: passportColor,
152
+ });
153
+ }
154
+
155
+ // Get user passport from vc
156
+ let passport = vc ? createUserPassport(vc) : null;
157
+ if (user && passport && isUserPassportRevoked(user, passport)) {
158
+ throw new Error(messages.passportRevoked[locale](name));
159
+ }
160
+
161
+ // Get role
162
+ let role = ROLES.GUEST;
163
+ if (vc) {
164
+ await validatePassport(get(vc, 'credentialSubject.passport'));
165
+ const issuerId = get(vc, 'issuer.id');
166
+ if (issuerId === teamAppDid) {
167
+ role = getRoleFromLocalPassport(get(vc, 'credentialSubject.passport'));
168
+ } else {
169
+ // map external passport to local role
170
+ const { mappings = [] } = (blocklet.trustedPassports || []).find((x) => x.issuerDid === issuerId) || {};
171
+ role = await getRoleFromExternalPassport({
172
+ passport: get(vc, 'credentialSubject.passport'),
173
+ node,
174
+ teamDid,
175
+ locale,
176
+ mappings,
177
+ });
178
+
179
+ // check status of external passport if passport has an endpoint
180
+ const endpoint = get(vc, 'credentialStatus.id');
181
+ if (endpoint) {
182
+ await validatePassportStatus({ vcId: vc.id, endpoint, locale });
183
+ }
184
+ }
185
+ }
186
+
187
+ if (config.whoCanAccess === WHO_CAN_ACCESS.OWNER && role !== ROLES.OWNER) {
188
+ throw new Error(
189
+ {
190
+ zh: '你不是该应用的所有者',
191
+ en: 'You are not the owner of this application',
192
+ }[locale]
193
+ );
194
+ }
195
+
196
+ // Recreate passport with correct role
197
+ passport = vc ? createUserPassport(vc, { role }) : null;
198
+
199
+ // Update profile
200
+ const passportForLog = passport || { name: 'Guest', role: 'guest' };
201
+ if (user) {
202
+ // Update user
203
+ const doc = await node.updateUser({
204
+ teamDid,
205
+ user: {
206
+ did: userDid,
207
+ pk: userPk,
208
+ locale,
209
+ passports: upsertToPassports(user.passports || [], passport).filter(Boolean),
210
+ lastLoginAt: new Date().toISOString(),
211
+ },
212
+ });
213
+ await node.createAuditLog(
214
+ {
215
+ action: 'login',
216
+ args: { teamDid, userDid, passport: passportForLog },
217
+ context: formatContext(Object.assign(request, { user: doc })),
218
+ result: doc,
219
+ },
220
+ node
221
+ );
222
+ } else {
223
+ // Create user
224
+ const profile = claims.find((x) => x.type === 'profile');
225
+
226
+ const doc = await node.addUser({
227
+ teamDid,
228
+ user: {
229
+ ...profile,
230
+ avatar: await extractUserAvatar(get(profile, 'avatar'), {
231
+ dataDir: blocklet.env.dataDir,
232
+ }),
233
+ did: userDid,
234
+ pk: userPk,
235
+ approved: true,
236
+ locale,
237
+ passports: [passport].filter(Boolean),
238
+ firstLoginAt: new Date().toISOString(),
239
+ lastLoginAt: new Date().toISOString(),
240
+ },
241
+ });
242
+ await node.createAuditLog(
243
+ {
244
+ action: 'addUser',
245
+ args: { teamDid, userDid, reason: `first login as ${passportForLog.role}` },
246
+ context: formatContext(Object.assign(request, { user: doc })),
247
+ result: doc,
248
+ },
249
+ node
250
+ );
251
+ }
252
+
253
+ // Generate new session token that client can save to localStorage
254
+ const sessionToken = await createSessionToken(userDid, { passport, role });
255
+ logger.info('login.success', { userDid, role });
256
+
257
+ if (
258
+ // if user provides owner passport AND app does not have owner, set this user to owner
259
+ (inputVC && role === ROLES.OWNER && !blocklet.settings?.owner) ||
260
+ // if the user will receive a owner passport AND app does not have owner, set this user to owner
261
+ (issuePassport && defaultRole === ROLES.OWNER && !blocklet.settings?.owner)
262
+ ) {
263
+ logger.info('Bind owner for blocklet', { teamDid, userDid });
264
+ await node.setBlockletInitialized({ did: teamDid, owner: { did: userDid, pk: userPk } });
265
+ }
266
+
267
+ // issue passport for the first login user in a invite-only team
268
+ if (issuePassport) {
269
+ return {
270
+ disposition: 'attachment',
271
+ type: 'VerifiableCredential',
272
+ data: vc,
273
+ sessionToken,
274
+ nextWorkflowData: {
275
+ userDid,
276
+ },
277
+ };
278
+ }
279
+
280
+ return {
281
+ sessionToken,
282
+ nextWorkflowData: {
283
+ userDid,
284
+ },
285
+ };
286
+ },
287
+ },
288
+
289
+ switchProfile: {
290
+ onConnect: async ({ node, request, locale, userDid, previousUserDid }) => {
291
+ if (userDid && previousUserDid && userDid !== previousUserDid) {
292
+ throw new Error(messages.userMismatch[locale]);
293
+ }
294
+
295
+ const config = await request.getServiceConfig(NODE_SERVICES.AUTH);
296
+ if (get(config, 'allowSwitchProfile', true) === false) {
297
+ throw new Error(messages.actionForbidden[locale]);
298
+ }
299
+
300
+ const { did: teamDid } = await request.getBlockletInfo();
301
+ const user = await getUser(node, teamDid, userDid);
302
+
303
+ if (!user) {
304
+ throw new Error(messages.userNotFound[locale]);
305
+ }
306
+ if (!user.approved) {
307
+ throw new Error(messages.notAuthorized[locale]);
308
+ }
309
+
310
+ return {
311
+ profile: {
312
+ type: 'profile',
313
+ description: messages.description[locale],
314
+ items: get(config, 'profileFields') || ['fullName', 'avatar'],
315
+ },
316
+ };
317
+ },
318
+ onApprove: async ({ node, request, locale, profile, userDid }) => {
319
+ const blocklet = await request.getBlocklet();
320
+ const teamDid = blocklet.meta.did;
321
+
322
+ // check user approved
323
+ const user = await getUser(node, teamDid, userDid);
324
+ if (!user) {
325
+ throw new Error(messages.userNotFound[locale]);
326
+ }
327
+ if (!user.approved) {
328
+ throw new Error(messages.notAuthorized[locale]);
329
+ }
330
+
331
+ // Update user
332
+ const doc = await node.updateUser({
333
+ teamDid,
334
+ user: {
335
+ ...user,
336
+ ...profile,
337
+ avatar: await extractUserAvatar(get(profile, 'avatar'), {
338
+ dataDir: blocklet.env.dataDir,
339
+ }),
340
+ locale,
341
+ },
342
+ });
343
+ await node.createAuditLog(
344
+ {
345
+ action: 'switchProfile',
346
+ args: { teamDid, userDid, profile },
347
+ context: formatContext(Object.assign(request, { user })),
348
+ result: doc,
349
+ },
350
+ node
351
+ );
352
+ },
353
+ },
354
+
355
+ switchPassport: {
356
+ onConnect: async ({ node, request, locale, userDid, previousUserDid }) => {
357
+ if (userDid && previousUserDid && userDid !== previousUserDid) {
358
+ throw new Error(messages.userMismatch[locale]);
359
+ }
360
+
361
+ const { wallet, did: teamDid } = await request.getBlockletInfo();
362
+ const user = await getUser(node, teamDid, userDid);
363
+
364
+ if (!user) {
365
+ throw new Error(messages.userNotFound[locale]);
366
+ }
367
+ if (!user.approved) {
368
+ throw new Error(messages.notAuthorized[locale]);
369
+ }
370
+
371
+ const blocklet = await request.getBlocklet();
372
+ const trustedPassports = (blocklet.trustedPassports || []).map((x) => x.issuerDid);
373
+ const trustedIssuers = [wallet.address, ...trustedPassports].filter(Boolean);
374
+
375
+ return {
376
+ verifiableCredential: {
377
+ type: 'verifiableCredential',
378
+ description: messages.requestPassport[locale],
379
+ item: vcTypes,
380
+ trustedIssuers,
381
+ optional: false,
382
+ },
383
+ };
384
+ },
385
+ onApprove: async ({ node, request, locale, challenge, verifiableCredential, userDid, createSessionToken }) => {
386
+ const blocklet = await request.getBlocklet();
387
+ const { wallet, name, did: teamDid } = await request.getBlockletInfo();
388
+
389
+ // Validate user
390
+ const user = await getUser(node, teamDid, userDid);
391
+ if (!user) {
392
+ throw new Error(messages.userNotFound[locale]);
393
+ }
394
+ if (!user.approved) {
395
+ throw new Error(messages.notAuthorized[locale]);
396
+ }
397
+
398
+ // Get passport
399
+ const trustedPassports = (blocklet.trustedPassports || []).map((x) => x.issuerDid);
400
+ const trustedIssuers = [wallet.address, ...trustedPassports].filter(Boolean);
401
+ const { vc } = await getVCFromClaims({
402
+ claims: [verifiableCredential],
403
+ challenge,
404
+ trustedIssuers,
405
+ vcTypes,
406
+ locale,
407
+ });
408
+
409
+ // Get user passport from vc
410
+ let passport = createUserPassport(vc);
411
+ if (passport && isUserPassportRevoked(user, passport)) {
412
+ throw new Error(messages.passportRevoked[locale](name));
413
+ }
414
+
415
+ // Get role
416
+ let role = ROLES.GUEST;
417
+ await validatePassport(get(vc, 'credentialSubject.passport'));
418
+ const issuerId = get(vc, 'issuer.id');
419
+ if (issuerId === wallet.address) {
420
+ role = getRoleFromLocalPassport(get(vc, 'credentialSubject.passport'));
421
+ } else {
422
+ // map external passport to local role
423
+ const { mappings = [] } = (blocklet.trustedPassports || []).find((x) => x.issuerDid === issuerId) || {};
424
+ role = await getRoleFromExternalPassport({
425
+ passport: get(vc, 'credentialSubject.passport'),
426
+ node,
427
+ teamDid,
428
+ locale,
429
+ mappings,
430
+ });
431
+
432
+ // check status of external passport if passport has an endpoint
433
+ const endpoint = get(vc, 'credentialStatus.id');
434
+ if (endpoint) {
435
+ await validatePassportStatus({ vcId: vc.id, endpoint, locale });
436
+ }
437
+ }
438
+
439
+ // Recreate passport with correct role
440
+ passport = createUserPassport(vc, { role });
441
+ await node.createAuditLog(
442
+ {
443
+ action: 'switchPassport',
444
+ args: { teamDid, userDid, passport },
445
+ context: formatContext(Object.assign(request, { user })),
446
+ result: {},
447
+ },
448
+ node
449
+ );
450
+
451
+ // Generate new session token that client can save to localStorage
452
+ const sessionToken = await createSessionToken(userDid, { passport, role });
453
+ return sessionToken;
454
+ },
455
+ },
456
+ };
@@ -5,6 +5,8 @@ const createHandlers = require('@blocklet/sdk/lib/connect/handler');
5
5
  const { sendToUser } = require('@blocklet/sdk/lib/util/send-notification');
6
6
  const { WELLKNOWN_SERVICE_PATH_PREFIX } = require('@abtnode/constant');
7
7
 
8
+ const logger = require('@abtnode/logger')(require('../../../package.json').name);
9
+
8
10
  const { appInfo, chainInfo } = require('./shared');
9
11
 
10
12
  module.exports = (node, opts) => {
@@ -18,6 +20,7 @@ module.exports = (node, opts) => {
18
20
  });
19
21
 
20
22
  const handlers = createHandlers({
23
+ logger,
21
24
  authenticator,
22
25
  storage: new NedbStorage({
23
26
  dbPath: path.join(opts.dataDir, 'sessions.db'),
@@ -1,14 +1,14 @@
1
1
  const { WELLKNOWN_SERVICE_PATH_PREFIX } = require('@abtnode/constant');
2
2
  const { attachHandlers } = require('@did-connect/relay-adapter-express');
3
- const initConnectRelay = require('../libs/connect/v2');
3
+ const initConnectRelay = require('../../libs/connect/v2');
4
4
 
5
5
  module.exports = {
6
- init(server, node, opts, wsRouter) {
6
+ init(router, node, opts, wsRouter) {
7
7
  const { handlers } = initConnectRelay(node, opts);
8
8
  const prefix = `${WELLKNOWN_SERVICE_PATH_PREFIX}/api/connect/relay`;
9
9
 
10
10
  // attach http handlers
11
- attachHandlers(server, handlers, prefix);
11
+ attachHandlers(router, handlers, prefix);
12
12
 
13
13
  // attach ws handlers
14
14
  wsRouter.use(`${prefix}/websocket`, handlers.wsServer.onConnect.bind(handlers.wsServer));
@@ -0,0 +1,120 @@
1
+ const { WELLKNOWN_SERVICE_PATH_PREFIX } = require('@abtnode/constant');
2
+ const { switchProfile, switchPassport, login } = require('../../libs/connect/session');
3
+ const initJwt = require('../../libs/jwt');
4
+
5
+ module.exports = {
6
+ init(server, node, options) {
7
+ const prefix = `${WELLKNOWN_SERVICE_PATH_PREFIX}/api/connect/relay`;
8
+
9
+ // api for login
10
+ server.post(`${prefix}/login/connect`, async (req, res) => {
11
+ try {
12
+ const { locale, currentConnected } = req.body;
13
+ const claim = await login.onConnect({
14
+ node,
15
+ request: req,
16
+ locale,
17
+ userDid: currentConnected.userDid,
18
+ userPk: currentConnected.userPk,
19
+ passportId: req.query.passportId || '',
20
+ });
21
+ res.json([[claim.profile, claim.verifiableCredential].filter(Boolean)]);
22
+ } catch (err) {
23
+ console.error(err);
24
+ res.json({ error: err.message });
25
+ }
26
+ });
27
+ server.post(`${prefix}/login/approve`, async (req, res) => {
28
+ try {
29
+ const { locale, currentStep, challenge, authUrl, currentConnected, responseClaims } = req.body;
30
+ const { createSessionToken } = initJwt(node, options);
31
+ const result = await login.onApprove({
32
+ node,
33
+ request: req,
34
+ locale,
35
+ challenge,
36
+ claims: responseClaims[currentStep],
37
+ userDid: currentConnected.userDid,
38
+ userPk: currentConnected.userPk,
39
+ baseUrl: new URL(authUrl).origin,
40
+ createSessionToken,
41
+ });
42
+ res.json(result);
43
+ } catch (err) {
44
+ console.error(err);
45
+ res.json({ error: err.message });
46
+ }
47
+ });
48
+
49
+ // api for switch profile
50
+ server.post(`${prefix}/switch-profile/connect`, async (req, res) => {
51
+ try {
52
+ const { locale, currentConnected, previousConnected } = req.body;
53
+ const claim = await switchProfile.onConnect({
54
+ node,
55
+ request: req,
56
+ locale,
57
+ userDid: currentConnected.userDid,
58
+ previousUserDid: previousConnected?.userDid,
59
+ });
60
+ res.json([[{ type: 'profile', ...claim.profile }]]);
61
+ } catch (err) {
62
+ console.error(err);
63
+ res.json({ error: err.message });
64
+ }
65
+ });
66
+ server.post(`${prefix}/switch-profile/approve`, async (req, res) => {
67
+ try {
68
+ const { locale, currentConnected, responseClaims, currentStep } = req.body;
69
+ const result = await switchProfile.onApprove({
70
+ node,
71
+ request: req,
72
+ locale,
73
+ profile: responseClaims[currentStep].find((x) => x.type === 'profile'),
74
+ userDid: currentConnected.userDid,
75
+ });
76
+ res.json(result);
77
+ } catch (err) {
78
+ console.error(err);
79
+ res.json({ error: err.message });
80
+ }
81
+ });
82
+
83
+ // api for switch passport
84
+ server.post(`${prefix}/switch-passport/connect`, async (req, res) => {
85
+ try {
86
+ const { locale, currentConnected, previousConnected } = req.body;
87
+ const claim = await switchPassport.onConnect({
88
+ node,
89
+ request: req,
90
+ locale,
91
+ userDid: currentConnected.userDid,
92
+ previousUserDid: previousConnected?.userDid,
93
+ });
94
+ res.json([[{ type: 'verifiableCredential', ...claim.verifiableCredential }]]);
95
+ } catch (err) {
96
+ console.error(err);
97
+ res.json({ error: err.message });
98
+ }
99
+ });
100
+ server.post(`${prefix}/switch-passport/approve`, async (req, res) => {
101
+ try {
102
+ const { locale, currentConnected, responseClaims, challenge, currentStep } = req.body;
103
+ const { createSessionToken } = initJwt(node, options);
104
+ const sessionToken = await switchPassport.onApprove({
105
+ node,
106
+ request: req,
107
+ locale,
108
+ challenge,
109
+ verifiableCredential: responseClaims[currentStep].find((x) => x.type === 'verifiableCredential'),
110
+ userDid: currentConnected.userDid,
111
+ createSessionToken,
112
+ });
113
+ res.json({ sessionToken });
114
+ } catch (err) {
115
+ console.error(err);
116
+ res.json({ error: err.message });
117
+ }
118
+ });
119
+ },
120
+ };