@abtnode/auth 1.16.5 → 1.16.6-beta-4562aa60
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/lib/auth.js +74 -3
- package/lib/passport.js +2 -0
- package/lib/server.js +26 -46
- package/lib/util/create-passport-svg.js +16 -12
- package/package.json +14 -14
package/lib/auth.js
CHANGED
|
@@ -6,8 +6,11 @@ const get = require('lodash/get');
|
|
|
6
6
|
const { verifyPresentation, createCredentialList } = require('@arcblock/vc');
|
|
7
7
|
const formatContext = require('@abtnode/util/lib/format-context');
|
|
8
8
|
const getNodeWallet = require('@abtnode/util/lib/get-app-wallet');
|
|
9
|
+
const { getChainClient } = require('@abtnode/util/lib/get-chain-client');
|
|
10
|
+
const { fromPublicKey } = require('@ocap/wallet');
|
|
11
|
+
const { fromBase58, toAddress } = require('@ocap/util');
|
|
12
|
+
const { toTypeInfo, isFromPublicKey } = require('@arcblock/did');
|
|
9
13
|
const Mcrypto = require('@ocap/mcrypto');
|
|
10
|
-
const Client = require('@ocap/client');
|
|
11
14
|
const getBlockletInfo = require('@blocklet/meta/lib/info');
|
|
12
15
|
const {
|
|
13
16
|
PASSPORT_STATUS,
|
|
@@ -41,6 +44,10 @@ const messages = {
|
|
|
41
44
|
en: 'Please provide following information to continue',
|
|
42
45
|
zh: '请提供如下信息以继续',
|
|
43
46
|
},
|
|
47
|
+
requestNFT: {
|
|
48
|
+
en: 'Please present NFT to exchange for passport',
|
|
49
|
+
zh: '请提供 NFT 以换取通行证',
|
|
50
|
+
},
|
|
44
51
|
requestCredential: {
|
|
45
52
|
en: 'Please provide credential',
|
|
46
53
|
zh: '请提供凭证',
|
|
@@ -72,6 +79,10 @@ const messages = {
|
|
|
72
79
|
en: 'This node is not initialized, login is disabled',
|
|
73
80
|
zh: '节点初始化未完成,禁止任何 DID 登录',
|
|
74
81
|
},
|
|
82
|
+
appNotInitialized: {
|
|
83
|
+
en: 'This application is not initialized',
|
|
84
|
+
zh: '应用未初始化未完成',
|
|
85
|
+
},
|
|
75
86
|
alreadyInitiated: {
|
|
76
87
|
en: 'This Node already have an owner verified',
|
|
77
88
|
zh: '该节点已经绑定所有者',
|
|
@@ -80,6 +91,10 @@ const messages = {
|
|
|
80
91
|
en: 'You are not allowed to login to this node',
|
|
81
92
|
zh: '你没有权限登录该节点',
|
|
82
93
|
},
|
|
94
|
+
notAppOwner: {
|
|
95
|
+
en: 'You are not the owner of this application',
|
|
96
|
+
zh: '你不是该应用的所有者',
|
|
97
|
+
},
|
|
83
98
|
missingCredentialClaim: {
|
|
84
99
|
en: 'Credential is not provided',
|
|
85
100
|
zh: '请提供凭证',
|
|
@@ -214,6 +229,10 @@ const messages = {
|
|
|
214
229
|
en: 'Invalid server ownership NFT issuer',
|
|
215
230
|
zh: '无效的节点所有权 NFT 颁发者',
|
|
216
231
|
},
|
|
232
|
+
invalidNftParent: {
|
|
233
|
+
en: 'Unexpected NFT Collection',
|
|
234
|
+
zh: '无效的 NFT 集合',
|
|
235
|
+
},
|
|
217
236
|
tagNotMatch: {
|
|
218
237
|
en: 'This NFT is for another blocklet server',
|
|
219
238
|
zh: '您所提供的所有权 NFT 不属于当前节点',
|
|
@@ -234,6 +253,10 @@ const messages = {
|
|
|
234
253
|
en: 'This NFT has expired',
|
|
235
254
|
zh: '该 NFT 已经过期',
|
|
236
255
|
},
|
|
256
|
+
nftAlreadyUsed: {
|
|
257
|
+
en: 'This NFT has already been connected to another user',
|
|
258
|
+
zh: '该 NFT 已经被起它用户使用过',
|
|
259
|
+
},
|
|
237
260
|
missingNftClaim: {
|
|
238
261
|
en: 'Ownership NFT not provided',
|
|
239
262
|
zh: '节点所有权 NFT 必须提供',
|
|
@@ -266,6 +289,10 @@ const messages = {
|
|
|
266
289
|
en: 'tag is required',
|
|
267
290
|
zh: 'tag 不能为空',
|
|
268
291
|
},
|
|
292
|
+
appIsInProgress: {
|
|
293
|
+
en: 'The application is in progress, please wait for it to finish',
|
|
294
|
+
zh: '应用正在进行中,请等待完成',
|
|
295
|
+
},
|
|
269
296
|
};
|
|
270
297
|
|
|
271
298
|
const PASSPORT_STATUS_KEY = 'passport-status';
|
|
@@ -288,7 +315,7 @@ const getRandomMessage = (len = 16) => {
|
|
|
288
315
|
return hex.replace(/^0x/, '').toUpperCase();
|
|
289
316
|
};
|
|
290
317
|
|
|
291
|
-
const getApplicationInfo = async ({ node, nodeInfo, teamDid }) => {
|
|
318
|
+
const getApplicationInfo = async ({ node, nodeInfo = {}, teamDid }) => {
|
|
292
319
|
let type;
|
|
293
320
|
let name;
|
|
294
321
|
let wallet;
|
|
@@ -454,7 +481,7 @@ const handleInvitationReceive = async ({
|
|
|
454
481
|
|
|
455
482
|
if (get(nodeInfo, 'ownerNft.holder')) {
|
|
456
483
|
// 这种情况下是 Transfer 有 Owner NFT 的 Blocklet Server
|
|
457
|
-
const client =
|
|
484
|
+
const client = getChainClient(nodeInfo.launcher.chainHost);
|
|
458
485
|
const ownerNftDid = get(nodeInfo, 'ownerNft.did');
|
|
459
486
|
|
|
460
487
|
const { state: assetState } = await client.getAssetState({ address: ownerNftDid });
|
|
@@ -516,6 +543,11 @@ const handleInvitationReceive = async ({
|
|
|
516
543
|
const user = await getUser(node, teamDid, userDid);
|
|
517
544
|
|
|
518
545
|
if (role === 'owner') {
|
|
546
|
+
if (issuerType === TEAM_TYPE.blocklet) {
|
|
547
|
+
// should not be here
|
|
548
|
+
throw new Error('not allowed to transfer application ownership');
|
|
549
|
+
}
|
|
550
|
+
|
|
519
551
|
if (user && user.role === 'owner') {
|
|
520
552
|
throw new Error(messages.alreadyTransferred[locale](userDid));
|
|
521
553
|
}
|
|
@@ -1052,9 +1084,48 @@ const setUserInfoHeaders = (req) => {
|
|
|
1052
1084
|
}
|
|
1053
1085
|
};
|
|
1054
1086
|
|
|
1087
|
+
const verifyNFT = async ({ claims, challenge, chainHost, locale }) => {
|
|
1088
|
+
const client = getChainClient(chainHost);
|
|
1089
|
+
const claim = claims.find((x) => x.type === 'asset');
|
|
1090
|
+
if (!claim) {
|
|
1091
|
+
throw new Error(messages.missingNftClaim[locale]);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const fields = ['asset', 'ownerProof', 'ownerPk', 'ownerDid'];
|
|
1095
|
+
for (const field of fields) {
|
|
1096
|
+
if (!claim[field]) {
|
|
1097
|
+
throw new Error(messages.invalidNftClaim[locale]);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const address = claim.asset;
|
|
1102
|
+
const ownerDid = toAddress(claim.ownerDid);
|
|
1103
|
+
const ownerPk = fromBase58(claim.ownerPk);
|
|
1104
|
+
const ownerProof = fromBase58(claim.ownerProof);
|
|
1105
|
+
if (isFromPublicKey(ownerDid, ownerPk) === false) {
|
|
1106
|
+
throw new Error(messages.invalidNftHolder[locale]);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const owner = fromPublicKey(ownerPk, toTypeInfo(ownerDid));
|
|
1110
|
+
if (owner.verify(challenge, ownerProof) === false) {
|
|
1111
|
+
throw new Error(messages.invalidNftProof[locale]);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const { state } = await client.getAssetState({ address }, { ignoreFields: ['context'] });
|
|
1115
|
+
if (!state) {
|
|
1116
|
+
throw new Error(messages.invalidNft[locale]);
|
|
1117
|
+
}
|
|
1118
|
+
if (state.owner !== ownerDid) {
|
|
1119
|
+
throw new Error(messages.invalidNftHolder[locale]);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return state;
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1055
1125
|
module.exports = {
|
|
1056
1126
|
getUser,
|
|
1057
1127
|
getApplicationInfo,
|
|
1128
|
+
verifyNFT,
|
|
1058
1129
|
createAuthToken,
|
|
1059
1130
|
createAuthTokenByOwnershipNFT,
|
|
1060
1131
|
beforeInvitationRequest,
|
package/lib/passport.js
CHANGED
|
@@ -63,6 +63,7 @@ const createPassportVC = ({
|
|
|
63
63
|
tag,
|
|
64
64
|
ownerProfile,
|
|
65
65
|
preferredColor,
|
|
66
|
+
expirationDate,
|
|
66
67
|
} = {}) => {
|
|
67
68
|
validatePassport(passport);
|
|
68
69
|
|
|
@@ -87,6 +88,7 @@ const createPassportVC = ({
|
|
|
87
88
|
}),
|
|
88
89
|
},
|
|
89
90
|
},
|
|
91
|
+
expirationDate,
|
|
90
92
|
endpoint,
|
|
91
93
|
tag,
|
|
92
94
|
});
|
package/lib/server.js
CHANGED
|
@@ -6,11 +6,9 @@ const uniq = require('lodash/uniq');
|
|
|
6
6
|
const pRetry = require('p-retry');
|
|
7
7
|
const { isNFTExpired, isNFTConsumed } = require('@abtnode/util/lib/nft');
|
|
8
8
|
const axios = require('@abtnode/util/lib/axios');
|
|
9
|
-
const Client = require('@ocap/client');
|
|
10
|
-
const { fromPublicKey } = require('@ocap/wallet');
|
|
11
9
|
const { types } = require('@ocap/mcrypto');
|
|
12
|
-
const {
|
|
13
|
-
const {
|
|
10
|
+
const { toHex } = require('@ocap/util');
|
|
11
|
+
const { DidType, isEthereumType } = require('@arcblock/did');
|
|
14
12
|
const urlPathFriendly = require('@blocklet/meta/lib/url-path-friendly').default;
|
|
15
13
|
const getApplicationWallet = require('@blocklet/meta/lib/wallet');
|
|
16
14
|
const { slugify } = require('transliteration');
|
|
@@ -38,6 +36,7 @@ const {
|
|
|
38
36
|
createBlockletControllerAuthToken,
|
|
39
37
|
checkWalletVersion,
|
|
40
38
|
checkWalletVersionForMigrateAppToV2,
|
|
39
|
+
verifyNFT,
|
|
41
40
|
} = require('./auth');
|
|
42
41
|
const {
|
|
43
42
|
validatePassport,
|
|
@@ -193,41 +192,9 @@ const authenticateByVc = async ({
|
|
|
193
192
|
|
|
194
193
|
const authenticateByNFT = async ({ node, claims, userDid, challenge, locale, isAuth, chainHost }) => {
|
|
195
194
|
const info = await node.getNodeInfo();
|
|
196
|
-
// serverless 应用通过 querystring 传递 chainHost
|
|
197
|
-
const client = new Client(chainHost || info.launcher.chainHost);
|
|
198
|
-
|
|
199
|
-
const claim = claims.find((x) => x.type === 'asset');
|
|
200
|
-
if (!claim) {
|
|
201
|
-
throw new Error(messages.missingNftClaim[locale]);
|
|
202
|
-
}
|
|
203
195
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (!claim[field]) {
|
|
207
|
-
throw new Error(messages.invalidNftClaim[locale]);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const address = claim.asset;
|
|
212
|
-
const ownerDid = toAddress(claim.ownerDid);
|
|
213
|
-
const ownerPk = fromBase58(claim.ownerPk);
|
|
214
|
-
const ownerProof = fromBase58(claim.ownerProof);
|
|
215
|
-
if (isFromPublicKey(ownerDid, ownerPk) === false) {
|
|
216
|
-
throw new Error(messages.invalidNftHolder[locale]);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const owner = fromPublicKey(ownerPk, toTypeInfo(ownerDid));
|
|
220
|
-
if (owner.verify(challenge, ownerProof) === false) {
|
|
221
|
-
throw new Error(messages.invalidNftProof[locale]);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const { state } = await client.getAssetState({ address }, { ignoreFields: ['context'] });
|
|
225
|
-
if (!state) {
|
|
226
|
-
throw new Error(messages.invalidNft[locale]);
|
|
227
|
-
}
|
|
228
|
-
if (state.owner !== ownerDid) {
|
|
229
|
-
throw new Error(messages.invalidNftHolder[locale]);
|
|
230
|
-
}
|
|
196
|
+
// serverless 应用通过 querystring 传递 chainHost
|
|
197
|
+
const state = await verifyNFT({ claims, challenge, chainHost: chainHost || info.launcher.chainHost, locale });
|
|
231
198
|
|
|
232
199
|
const trustedLaunchers = await getLauncherAppIdList(get(info, 'launcher.url'));
|
|
233
200
|
if (!trustedLaunchers.includes(state.issuer)) {
|
|
@@ -246,7 +213,7 @@ const authenticateByNFT = async ({ node, claims, userDid, challenge, locale, isA
|
|
|
246
213
|
nft: state,
|
|
247
214
|
extra: {
|
|
248
215
|
controller: {
|
|
249
|
-
nftId: address,
|
|
216
|
+
nftId: state.address,
|
|
250
217
|
nftOwner: state.owner,
|
|
251
218
|
chainHost,
|
|
252
219
|
appMaxCount: state.data.value.appMaxCount || 1,
|
|
@@ -265,7 +232,15 @@ const authenticateByNFT = async ({ node, claims, userDid, challenge, locale, isA
|
|
|
265
232
|
}
|
|
266
233
|
|
|
267
234
|
const user = await getUser(node, info.did, userDid);
|
|
268
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
role: ROLES.OWNER,
|
|
237
|
+
teamDid: info.did,
|
|
238
|
+
nft: state,
|
|
239
|
+
user,
|
|
240
|
+
passport: null,
|
|
241
|
+
ownerDid: state.owner,
|
|
242
|
+
ownerNFT: state.address,
|
|
243
|
+
};
|
|
269
244
|
};
|
|
270
245
|
|
|
271
246
|
const authenticateBySession = async ({ node, userDid, locale, allowedRoles = ['owner', 'admin', 'member'] }) => {
|
|
@@ -372,17 +347,21 @@ const getAuthPrincipalForMigrateAppToV2 = (node) => async (param) => {
|
|
|
372
347
|
};
|
|
373
348
|
};
|
|
374
349
|
|
|
350
|
+
const getAuthPrincipalForTransferAppOwnerShip = getAuthPrincipalForMigrateAppToV2;
|
|
351
|
+
|
|
375
352
|
const getKeyPairClaim =
|
|
376
|
-
(node,
|
|
353
|
+
(node, opts = {}) =>
|
|
377
354
|
async ({ extraParams: { locale, appDid, wt = 'default', title }, context: { didwallet } }) => {
|
|
378
355
|
checkWalletVersion({ didwallet, locale });
|
|
379
356
|
|
|
357
|
+
const { declare = true } = opts;
|
|
358
|
+
|
|
380
359
|
const description = {
|
|
381
360
|
en: 'Please generate a new key-pair for this application',
|
|
382
361
|
zh: '请为应用创建新的钥匙对',
|
|
383
362
|
};
|
|
384
363
|
|
|
385
|
-
let appName = title;
|
|
364
|
+
let appName = title || opts.title;
|
|
386
365
|
let migrateFrom = '';
|
|
387
366
|
|
|
388
367
|
let type;
|
|
@@ -416,7 +395,7 @@ const getKeyPairClaim =
|
|
|
416
395
|
const result = {
|
|
417
396
|
mfa: !process.env.DID_CONNECT_MFA_DISABLED,
|
|
418
397
|
description: description[locale] || description.en,
|
|
419
|
-
moniker: (urlPathFriendly(slugify(appName)) || 'application').toLowerCase(),
|
|
398
|
+
moniker: (urlPathFriendly(slugify(appName || 'application')) || 'application').toLowerCase(),
|
|
420
399
|
declare: !!declare,
|
|
421
400
|
migrateFrom: declare ? migrateFrom : '',
|
|
422
401
|
chainInfo,
|
|
@@ -502,7 +481,7 @@ const getLaunchBlockletClaims = (node, authMethod) => {
|
|
|
502
481
|
return claims;
|
|
503
482
|
};
|
|
504
483
|
|
|
505
|
-
const
|
|
484
|
+
const getAppDidOwnerClaims = () => {
|
|
506
485
|
const description = {
|
|
507
486
|
en: 'Sign following message to prove that you are the owner of the app',
|
|
508
487
|
zh: '签名如下消息以证明你是应用的拥有者',
|
|
@@ -713,7 +692,7 @@ const createLaunchBlockletHandler =
|
|
|
713
692
|
|
|
714
693
|
// 如果是 serverless, 并且已经消费过了,但是没有安装,则抛出异常
|
|
715
694
|
if (!existedBlocklet && role === SERVER_ROLES.EXTERNAL_BLOCKLET_CONTROLLER && isNFTConsumed(nft)) {
|
|
716
|
-
throw new Error(messages.
|
|
695
|
+
throw new Error(messages.nftAlreadyConsumed[locale]);
|
|
717
696
|
}
|
|
718
697
|
|
|
719
698
|
if (existedBlocklet) {
|
|
@@ -853,10 +832,11 @@ module.exports = {
|
|
|
853
832
|
createRestoreOnServerlessHandler,
|
|
854
833
|
ensureBlockletPermission,
|
|
855
834
|
getBlockletPermissionChecker,
|
|
856
|
-
|
|
835
|
+
getAppDidOwnerClaims,
|
|
857
836
|
getTrustedIssuers,
|
|
858
837
|
getAuthNFTClaim,
|
|
859
838
|
getServerlessNFTClaim,
|
|
860
839
|
getLauncherAppIdList,
|
|
861
840
|
getAuthPrincipalForMigrateAppToV2,
|
|
841
|
+
getAuthPrincipalForTransferAppOwnerShip,
|
|
862
842
|
};
|
|
@@ -3,14 +3,18 @@ const { getNftBGColor, DEFAULT_COLOR, getNftBGColorFromDid } = require('./passpo
|
|
|
3
3
|
/**
|
|
4
4
|
* Generate Passport SVG
|
|
5
5
|
*
|
|
6
|
-
* @param {
|
|
7
|
-
* @param {string}
|
|
8
|
-
* @param {string}
|
|
9
|
-
* @param {
|
|
10
|
-
* @param {string}
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {
|
|
6
|
+
* @param {object} params
|
|
7
|
+
* @param {string} params.title passport title
|
|
8
|
+
* @param {string} params.issuer issuer name
|
|
9
|
+
* @param {string} params.issuerDid
|
|
10
|
+
* @param {string} params.ownerName
|
|
11
|
+
* @param {string} params.ownerAvatarUrl
|
|
12
|
+
* @param {string} [params.preferredColor]
|
|
13
|
+
* @param {string} [params.width]
|
|
14
|
+
* @param {string} [params.height]
|
|
15
|
+
* @param {boolean} [params.ownerAvatarUrl]
|
|
16
|
+
* @param {boolean} [params.revoked] 是否撤销
|
|
17
|
+
* @param {boolean} [params.isDataUrl] 返回生成 data url
|
|
14
18
|
* @returns {string} svg xml or image data url
|
|
15
19
|
*/
|
|
16
20
|
const createPassportSvg = ({
|
|
@@ -18,13 +22,13 @@ const createPassportSvg = ({
|
|
|
18
22
|
title = '',
|
|
19
23
|
issuerDid = '',
|
|
20
24
|
ownerName = '',
|
|
21
|
-
preferredColor = 'default',
|
|
22
25
|
ownerAvatarUrl = '',
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
preferredColor = 'default',
|
|
27
|
+
revoked = false,
|
|
28
|
+
isDataUrl = false,
|
|
25
29
|
width = '100%',
|
|
26
30
|
height = '100%',
|
|
27
|
-
}
|
|
31
|
+
}) => {
|
|
28
32
|
let colors;
|
|
29
33
|
if (preferredColor === 'default') {
|
|
30
34
|
colors = DEFAULT_COLOR;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.16.
|
|
6
|
+
"version": "1.16.6-beta-4562aa60",
|
|
7
7
|
"description": "Simple lib to manage auth in ABT Node",
|
|
8
8
|
"main": "lib/index.js",
|
|
9
9
|
"files": [
|
|
@@ -20,18 +20,18 @@
|
|
|
20
20
|
"author": "linchen <linchen1987@foxmail.com> (http://github.com/linchen1987)",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@abtnode/constant": "1.16.
|
|
24
|
-
"@abtnode/logger": "1.16.
|
|
25
|
-
"@abtnode/util": "1.16.
|
|
26
|
-
"@arcblock/did": "1.18.
|
|
27
|
-
"@arcblock/jwt": "^1.18.
|
|
28
|
-
"@arcblock/vc": "1.18.
|
|
29
|
-
"@blocklet/constant": "1.16.
|
|
30
|
-
"@blocklet/meta": "1.16.
|
|
31
|
-
"@ocap/client": "1.18.
|
|
32
|
-
"@ocap/mcrypto": "1.18.
|
|
33
|
-
"@ocap/util": "1.18.
|
|
34
|
-
"@ocap/wallet": "1.18.
|
|
23
|
+
"@abtnode/constant": "1.16.6-beta-4562aa60",
|
|
24
|
+
"@abtnode/logger": "1.16.6-beta-4562aa60",
|
|
25
|
+
"@abtnode/util": "1.16.6-beta-4562aa60",
|
|
26
|
+
"@arcblock/did": "1.18.72",
|
|
27
|
+
"@arcblock/jwt": "^1.18.72",
|
|
28
|
+
"@arcblock/vc": "1.18.72",
|
|
29
|
+
"@blocklet/constant": "1.16.6-beta-4562aa60",
|
|
30
|
+
"@blocklet/meta": "1.16.6-beta-4562aa60",
|
|
31
|
+
"@ocap/client": "1.18.72",
|
|
32
|
+
"@ocap/mcrypto": "1.18.72",
|
|
33
|
+
"@ocap/util": "1.18.72",
|
|
34
|
+
"@ocap/wallet": "1.18.72",
|
|
35
35
|
"axios": "^0.27.2",
|
|
36
36
|
"joi": "17.7.0",
|
|
37
37
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"jest": "^27.5.1"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "e49856099ffc3c16eda77480a7ad93b2e9760764"
|
|
48
48
|
}
|