From 67f36abb290a5aeed36c793746dc4ed717700cfd Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Mon, 10 Jun 2024 17:07:42 -0300 Subject: [PATCH 1/6] support deployment v2 --- src/adapters/deployment-v2-manager.ts | 134 ++++++++++++++++++ src/components.ts | 4 + .../handlers/deploy-v2-handlers.ts | 67 +++++++++ src/controllers/routes.ts | 6 + src/types.ts | 2 + 5 files changed, 213 insertions(+) create mode 100644 src/adapters/deployment-v2-manager.ts create mode 100644 src/controllers/handlers/deploy-v2-handlers.ts diff --git a/src/adapters/deployment-v2-manager.ts b/src/adapters/deployment-v2-manager.ts new file mode 100644 index 00000000..a3a697b6 --- /dev/null +++ b/src/adapters/deployment-v2-manager.ts @@ -0,0 +1,134 @@ +import { AppComponents, DeploymentResult } from '../types' +import { AuthChain, Entity } from '@dcl/schemas' +import { hashV1 } from '@dcl/hashing' +import { InvalidRequestError } from '@dcl/platform-server-commons' + +export type IDeploymentV2Manager = { + initDeployment(entityId: string, authChain: AuthChain, files: Record): Promise + addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise + completeDeployment(entityId: string): Promise +} + +export type StartDeploymentBody = { authChain: AuthChain; files: Record } + +export function createDeploymentV2Manager( + components: Pick +): IDeploymentV2Manager { + const { config, entityDeployer, logs, storage, validator } = components + const logger = logs.getLogger('deployment-v2-manager') + const ongoingDeploymentsRecord: Record = {} + const tempFiles: Record = {} + + async function initDeployment(entityId: string, authChain: AuthChain, files: Record): Promise { + logger.info(`Init deployment for entity ${entityId}`) + + // TODO Get entity from request + // TODO Validate parcels + // TODO Validate scene size against allowed + + // Check what files are already in storage and temporary storage and return the result + const results = Array.from((await storage.existMultiple(Object.keys(files))).entries()) + + ongoingDeploymentsRecord[entityId] = { authChain, files } + } + + async function addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise { + logger.info(`Received file ${fileHash} for entity ${entityId}`) + const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] + if (!ongoingDeploymentsRecordElement) { + throw new InvalidRequestError(`Deployment for entity ${entityId} not found`) + } + const computedFileHash = await hashV1(file) + if (computedFileHash !== entityId && fileHash !== computedFileHash) { + const expectedSize = ongoingDeploymentsRecordElement.files[fileHash] + if (!expectedSize) { + throw new InvalidRequestError(`File with hash ${fileHash} not expected in deployment for entity ${entityId}`) + } + if (expectedSize !== file.length) { + throw new InvalidRequestError( + `File with hash ${fileHash} has unexpected size in deployment for entity ${entityId}` + ) + } + } + + tempFiles[fileHash] = file + logger.info(`File ${fileHash} added to deployment for entity ${entityId}`) + } + + async function completeDeployment(entityId: string): Promise { + logger.info(`Completing deployment for entity ${entityId}`) + + const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] + if (!ongoingDeploymentsRecordElement) { + throw new Error(`Deployment for entity ${entityId} not found`) + } + + if (!tempFiles[entityId]) { + throw new Error(`Entity file not found in deployment for entity ${entityId}.`) + } + + const authChain = ongoingDeploymentsRecordElement.authChain + const entityRaw = tempFiles[entityId].toString() + if (!entityRaw) { + throw new Error(`Entity file not found in deployment for entity ${entityId}.`) + } + + const entityMetadataJson = JSON.parse(entityRaw.toString()) + + const entity: Entity = { + id: entityId, // this is not part of the published entity + timestamp: Date.now(), // this is not part of the published entity + ...entityMetadataJson + } + + const uploadedFiles: Map = new Map() + for (const fileHash of Object.keys(ongoingDeploymentsRecordElement.files)) { + if (tempFiles[fileHash]) { + uploadedFiles.set(fileHash, tempFiles[fileHash]) + } + } + + const contentHashesInStorage = await storage.existMultiple(Array.from(new Set(entity.content!.map(($) => $.hash)))) + + // run all validations about the deployment + const validationResult = await validator.validate({ + entity, + files: uploadedFiles, + authChain, + contentHashesInStorage + }) + if (!validationResult.ok()) { + throw new InvalidRequestError(`Deployment failed: ${validationResult.errors.join(', ')}`) + } + + // Store the entity + // TODO fix url + const baseUrl = (await config.getString('HTTP_BASE_URL'))! // || `https://${ctx.url.host}` + + // TODO separate file uploading to final storage from deploying the entity + const deploymentResult = await entityDeployer.deployEntity( + baseUrl, + entity, + contentHashesInStorage, + uploadedFiles, + entityRaw, + authChain + ) + + // Clean up temporary files + for (const fileHash in ongoingDeploymentsRecordElement.files) { + delete tempFiles[fileHash] + } + + // Clean up ongoing deployments + delete ongoingDeploymentsRecord[entityId] + + return deploymentResult + } + + return { + initDeployment, + addFileToDeployment, + completeDeployment + } +} diff --git a/src/components.ts b/src/components.ts index b60a8d81..99db0b48 100644 --- a/src/components.ts +++ b/src/components.ts @@ -36,6 +36,7 @@ import { createSnsClient } from './adapters/sns-client' import { createAwsConfig } from './adapters/aws-config' import { S3 } from 'aws-sdk' import { createNotificationsClientComponent } from './adapters/notifications-service' +import { createDeploymentV2Manager } from './adapters/deployment-v2-manager' // Initialize all the components of the app export async function initComponents(): Promise { @@ -134,6 +135,8 @@ export async function initComponents(): Promise { worldsManager }) + const deploymentV2Manager = createDeploymentV2Manager({ config, entityDeployer, logs, storage, validator }) + const migrationExecutor = createMigrationExecutor({ logs, database: database, nameOwnership, storage, worldsManager }) const notificationService = await createNotificationsClientComponent({ config, fetch, logs }) @@ -153,6 +156,7 @@ export async function initComponents(): Promise { commsAdapter, config, database, + deploymentV2Manager, entityDeployer, ethereumProvider, fetch, diff --git a/src/controllers/handlers/deploy-v2-handlers.ts b/src/controllers/handlers/deploy-v2-handlers.ts new file mode 100644 index 00000000..b1c99568 --- /dev/null +++ b/src/controllers/handlers/deploy-v2-handlers.ts @@ -0,0 +1,67 @@ +import { IHttpServerComponent } from '@well-known-components/interfaces' +import { HandlerContextWithPath } from '../../types' +import { AuthChain } from '@dcl/schemas' +import { InvalidRequestError } from '@dcl/platform-server-commons' +import { Authenticator } from '@dcl/crypto' +import { StartDeploymentBody } from '../../adapters/deployment-v2-manager' + +export function requireString(val: string | null | undefined): string { + if (typeof val !== 'string') throw new Error('A string was expected') + return val +} + +export async function startDeployEntity( + ctx: HandlerContextWithPath<'config' | 'deploymentV2Manager' | 'storage' | 'validator', '/v2/entities/:entityId'> +): Promise { + const entityId = await ctx.params.entityId + const body: StartDeploymentBody = await ctx.request.json() + const authChain: AuthChain = body.authChain + console.log('entityId', entityId, 'authChain', authChain, 'files', body.files) + + if (!AuthChain.validate(authChain)) { + throw new InvalidRequestError('Invalid authChain received') + } + if (!(await Authenticator.validateSignature(entityId, authChain, null, 10))) { + throw new InvalidRequestError('Invalid signature') + } + + await ctx.components.deploymentV2Manager.initDeployment(entityId, authChain, body.files) + + return { + status: 200, + body: { + creationTimestamp: Date.now() + } + } +} + +export async function deployFile( + ctx: HandlerContextWithPath<'deploymentV2Manager', '/v2/entities/:entityId/files/:fileHash'> +): Promise { + const entityId = await ctx.params.entityId + const fileHash = await ctx.params.fileHash + const buffer = await ctx.request.buffer() + + await ctx.components.deploymentV2Manager.addFileToDeployment(entityId, fileHash, buffer) + + return { + status: 204, + body: {} + } +} + +export async function finishDeployEntity( + ctx: HandlerContextWithPath< + 'config' | 'deploymentV2Manager' | 'entityDeployer' | 'storage' | 'validator', + '/v2/entities/:entityId' + > +): Promise { + const message = await ctx.components.deploymentV2Manager.completeDeployment(ctx.params.entityId) + return { + status: 204, + body: { + creationTimestamp: Date.now(), + message + } + } +} diff --git a/src/controllers/routes.ts b/src/controllers/routes.ts index ea0c2e38..42ef8116 100644 --- a/src/controllers/routes.ts +++ b/src/controllers/routes.ts @@ -23,6 +23,7 @@ import { bearerTokenMiddleware, errorHandler } from '@dcl/platform-server-common import { reprocessABHandler } from './handlers/reprocess-ab-handler' import { garbageCollectionHandler } from './handlers/garbage-collection' import { getContributableDomainsHandler } from './handlers/contributor-handler' +import { deployFile, finishDeployEntity, startDeployEntity } from './handlers/deploy-v2-handlers' export async function setupRouter(globalContext: GlobalContext): Promise> { const router = new Router() @@ -45,6 +46,11 @@ export async function setupRouter(globalContext: GlobalContext): Promise Date: Thu, 13 Jun 2024 12:34:18 -0300 Subject: [PATCH 2/6] wip --- src/adapters/deployment-v2-manager.ts | 32 ++++-- test/integration/deploy-v2-handlers.spec.ts | 115 ++++++++++++++++++++ 2 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 test/integration/deploy-v2-handlers.spec.ts diff --git a/src/adapters/deployment-v2-manager.ts b/src/adapters/deployment-v2-manager.ts index a3a697b6..f5a423c5 100644 --- a/src/adapters/deployment-v2-manager.ts +++ b/src/adapters/deployment-v2-manager.ts @@ -4,22 +4,31 @@ import { hashV1 } from '@dcl/hashing' import { InvalidRequestError } from '@dcl/platform-server-commons' export type IDeploymentV2Manager = { - initDeployment(entityId: string, authChain: AuthChain, files: Record): Promise + initDeployment( + entityId: string, + authChain: AuthChain, + files: Record + ): Promise addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise completeDeployment(entityId: string): Promise } export type StartDeploymentBody = { authChain: AuthChain; files: Record } +export type OngoingDeploymentMetadata = StartDeploymentBody & { availableFiles: string[]; missingFiles: string[] } export function createDeploymentV2Manager( components: Pick ): IDeploymentV2Manager { const { config, entityDeployer, logs, storage, validator } = components const logger = logs.getLogger('deployment-v2-manager') - const ongoingDeploymentsRecord: Record = {} + const ongoingDeploymentsRecord: Record = {} const tempFiles: Record = {} - async function initDeployment(entityId: string, authChain: AuthChain, files: Record): Promise { + async function initDeployment( + entityId: string, + authChain: AuthChain, + files: Record + ): Promise { logger.info(`Init deployment for entity ${entityId}`) // TODO Get entity from request @@ -29,7 +38,15 @@ export function createDeploymentV2Manager( // Check what files are already in storage and temporary storage and return the result const results = Array.from((await storage.existMultiple(Object.keys(files))).entries()) - ongoingDeploymentsRecord[entityId] = { authChain, files } + const ongoingDeploymentMetadata = { + authChain, + files, + availableFiles: results.filter(([_, available]) => !!available).map(([cid, _]) => cid), + missingFiles: results.filter(([_, available]) => !available).map(([cid, _]) => cid) + } + ongoingDeploymentsRecord[entityId] = ongoingDeploymentMetadata + + return ongoingDeploymentMetadata } async function addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise { @@ -39,11 +56,12 @@ export function createDeploymentV2Manager( throw new InvalidRequestError(`Deployment for entity ${entityId} not found`) } const computedFileHash = await hashV1(file) - if (computedFileHash !== entityId && fileHash !== computedFileHash) { - const expectedSize = ongoingDeploymentsRecordElement.files[fileHash] - if (!expectedSize) { + if (computedFileHash === entityId || fileHash === computedFileHash) { + if (!ongoingDeploymentsRecordElement.missingFiles.includes(fileHash)) { throw new InvalidRequestError(`File with hash ${fileHash} not expected in deployment for entity ${entityId}`) } + + const expectedSize = ongoingDeploymentsRecordElement.files[fileHash] if (expectedSize !== file.length) { throw new InvalidRequestError( `File with hash ${fileHash} has unexpected size in deployment for entity ${entityId}` diff --git a/test/integration/deploy-v2-handlers.spec.ts b/test/integration/deploy-v2-handlers.spec.ts new file mode 100644 index 00000000..99ccc9b2 --- /dev/null +++ b/test/integration/deploy-v2-handlers.spec.ts @@ -0,0 +1,115 @@ +import { test } from '../components' +import { ContentClient, createContentClient, DeploymentBuilder } from 'dcl-catalyst-client' +import { EntityType } from '@dcl/schemas' +import { Authenticator } from '@dcl/crypto' +import Sinon from 'sinon' +import { stringToUtf8Bytes } from 'eth-connect' +import { hashV1 } from '@dcl/hashing' +import { getIdentity, Identity, makeid } from '../utils' +import { IWorldCreator, IWorldsManager } from '../../src/types' +import { IContentStorageComponent } from '@dcl/catalyst-storage' + +test('deployment v2 works', function ({ components, stubComponents }) { + let contentClient: ContentClient + let identity: Identity + let worldName: string + let worldCreator: IWorldCreator + let worldsManager: IWorldsManager + let storage: IContentStorageComponent + + const entityFiles = new Map() + + beforeEach(async () => { + const { config } = components + + worldCreator = components.worldCreator + worldsManager = components.worldsManager + storage = components.storage + + identity = await getIdentity() + worldName = components.worldCreator.randomWorldName() + + contentClient = createContentClient({ + url: `http://${await config.requireString('HTTP_SERVER_HOST')}:${await config.requireNumber('HTTP_SERVER_PORT')}`, + fetcher: components.fetch + }) + + stubComponents.namePermissionChecker.checkPermission + .withArgs(identity.authChain.authChain[0].payload, worldName) + .resolves(true) + stubComponents.nameOwnership.findOwners + .withArgs([worldName]) + .resolves(new Map([[worldName, identity.authChain.authChain[0].payload]])) + stubComponents.metrics.increment.withArgs('world_deployments_counter', { kind: 'dcl-name' }) + }) + + it('creates an entity and deploys it (owner)', async () => { + const { storage, worldsManager } = components + const { snsClient } = stubComponents + + entityFiles.set('abc.txt', stringToUtf8Bytes(makeid(100))) + const fileHash = await hashV1(entityFiles.get('abc.txt')!) + + // Build the entity + const { files, entityId } = await DeploymentBuilder.buildEntity({ + type: EntityType.SCENE as any, + pointers: ['0,0'], + files: entityFiles, + metadata: { + main: 'abc.txt', + scene: { + base: '20,24', + parcels: ['20,24'] + }, + worldConfiguration: { + name: worldName, + miniMapConfig: { + enabled: true, + dataImage: 'abc.txt', + estateImage: 'abc.txt' + }, + skyboxConfig: { + textures: ['abc.txt'] + } + } + } + }) + + const authChain = Authenticator.signPayload(identity.authChain, entityId) + + snsClient.publish.resolves({ + MessageId: 'mocked-message-id', + SequenceNumber: 'mocked-sequence-number', + $metadata: {} + }) + + const response = (await contentClient.deploy({ files, entityId, authChain })) as Response + expect(await response.json()).toMatchObject({ + message: `Your scene was deployed to a Worlds Content Server!\nAccess world ${worldName}: https://play.decentraland.org/?realm=https%3A%2F%2F0.0.0.0%3A3000%2Fworld%2F${worldName}` + }) + + Sinon.assert.calledWith( + stubComponents.namePermissionChecker.checkPermission, + identity.authChain.authChain[0].payload, + worldName + ) + + expect(await storage.exist(fileHash)).toBeTruthy() + expect(await storage.exist(entityId)).toBeTruthy() + + const stored = await worldsManager.getMetadataForWorld(worldName) + expect(stored).toMatchObject({ + entityId, + runtimeMetadata: { + name: worldName, + entityIds: [entityId], + minimapDataImage: fileHash, + minimapEstateImage: fileHash, + minimapVisible: false, + skyboxTextures: [fileHash] + } + }) + + Sinon.assert.calledWithMatch(stubComponents.metrics.increment, 'world_deployments_counter', { kind: 'dcl-name' }) + }) +}) From 03d0830e49af07e2adfeaa2445414687c91a324d Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Fri, 14 Jun 2024 14:20:36 -0300 Subject: [PATCH 3/6] wip --- src/adapters/deployment-v2-manager.ts | 30 +++++------- src/components.ts | 14 +++++- .../handlers/deploy-entity-handler.ts | 24 ++------- .../handlers/deploy-v2-handlers.ts | 48 ++++++++++++------ src/controllers/routes.ts | 24 +++++++-- src/logic/extract-deployment-info.ts | 32 ++++++++++++ src/logic/multipart.ts | 2 +- src/logic/validations/scene.ts | 48 ++++++++++++++++++ src/logic/validations/validator.ts | 49 +++++++++++++++++++ src/types.ts | 7 +-- 10 files changed, 212 insertions(+), 66 deletions(-) create mode 100644 src/logic/extract-deployment-info.ts diff --git a/src/adapters/deployment-v2-manager.ts b/src/adapters/deployment-v2-manager.ts index f5a423c5..b3d46282 100644 --- a/src/adapters/deployment-v2-manager.ts +++ b/src/adapters/deployment-v2-manager.ts @@ -6,35 +6,33 @@ import { InvalidRequestError } from '@dcl/platform-server-commons' export type IDeploymentV2Manager = { initDeployment( entityId: string, + entityRaw: Buffer, authChain: AuthChain, files: Record ): Promise addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise - completeDeployment(entityId: string): Promise + completeDeployment(baseUrl: string, entityId: string): Promise } export type StartDeploymentBody = { authChain: AuthChain; files: Record } export type OngoingDeploymentMetadata = StartDeploymentBody & { availableFiles: string[]; missingFiles: string[] } export function createDeploymentV2Manager( - components: Pick + components: Pick ): IDeploymentV2Manager { - const { config, entityDeployer, logs, storage, validator } = components + const { entityDeployer, logs, storage, validator } = components const logger = logs.getLogger('deployment-v2-manager') const ongoingDeploymentsRecord: Record = {} const tempFiles: Record = {} async function initDeployment( entityId: string, + entityRaw: Buffer, authChain: AuthChain, files: Record ): Promise { logger.info(`Init deployment for entity ${entityId}`) - // TODO Get entity from request - // TODO Validate parcels - // TODO Validate scene size against allowed - // Check what files are already in storage and temporary storage and return the result const results = Array.from((await storage.existMultiple(Object.keys(files))).entries()) @@ -44,13 +42,15 @@ export function createDeploymentV2Manager( availableFiles: results.filter(([_, available]) => !!available).map(([cid, _]) => cid), missingFiles: results.filter(([_, available]) => !available).map(([cid, _]) => cid) } + ongoingDeploymentsRecord[entityId] = ongoingDeploymentMetadata + tempFiles[entityId] = entityRaw + return ongoingDeploymentMetadata } async function addFileToDeployment(entityId: string, fileHash: string, file: Buffer): Promise { - logger.info(`Received file ${fileHash} for entity ${entityId}`) const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] if (!ongoingDeploymentsRecordElement) { throw new InvalidRequestError(`Deployment for entity ${entityId} not found`) @@ -70,20 +70,16 @@ export function createDeploymentV2Manager( } tempFiles[fileHash] = file - logger.info(`File ${fileHash} added to deployment for entity ${entityId}`) + logger.info(`Received file ${fileHash} for entity ${entityId}`) } - async function completeDeployment(entityId: string): Promise { - logger.info(`Completing deployment for entity ${entityId}`) - + async function completeDeployment(baseUrl: string, entityId: string): Promise { const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] if (!ongoingDeploymentsRecordElement) { throw new Error(`Deployment for entity ${entityId} not found`) } - if (!tempFiles[entityId]) { - throw new Error(`Entity file not found in deployment for entity ${entityId}.`) - } + logger.info(`Completing deployment for entity ${entityId}`) const authChain = ongoingDeploymentsRecordElement.authChain const entityRaw = tempFiles[entityId].toString() @@ -120,10 +116,6 @@ export function createDeploymentV2Manager( } // Store the entity - // TODO fix url - const baseUrl = (await config.getString('HTTP_BASE_URL'))! // || `https://${ctx.url.host}` - - // TODO separate file uploading to final storage from deploying the entity const deploymentResult = await entityDeployer.deployEntity( baseUrl, entity, diff --git a/src/components.ts b/src/components.ts index 99db0b48..1f814276 100644 --- a/src/components.ts +++ b/src/components.ts @@ -22,7 +22,7 @@ import { createWorldsManagerComponent } from './adapters/worlds-manager' import { createCommsAdapterComponent } from './adapters/comms-adapter' import { createWorldsIndexerComponent } from './adapters/worlds-indexer' -import { createValidator } from './logic/validations' +import { createPreDeploymentValidator, createValidator } from './logic/validations' import { createEntityDeployer } from './adapters/entity-deployer' import { createMigrationExecutor } from './adapters/migration-executor' import { createNameDenyListChecker } from './adapters/name-deny-list-checker' @@ -135,7 +135,16 @@ export async function initComponents(): Promise { worldsManager }) - const deploymentV2Manager = createDeploymentV2Manager({ config, entityDeployer, logs, storage, validator }) + const preDeploymentValidator = createPreDeploymentValidator({ + config, + nameDenyListChecker, + namePermissionChecker, + limitsManager, + storage, + worldsManager + }) + + const deploymentV2Manager = createDeploymentV2Manager({ entityDeployer, logs, storage, validator }) const migrationExecutor = createMigrationExecutor({ logs, database: database, nameOwnership, storage, worldsManager }) @@ -170,6 +179,7 @@ export async function initComponents(): Promise { namePermissionChecker, notificationService, permissionsManager, + preDeploymentValidator, server, snsClient, status, diff --git a/src/controllers/handlers/deploy-entity-handler.ts b/src/controllers/handlers/deploy-entity-handler.ts index 608767b1..404c6678 100644 --- a/src/controllers/handlers/deploy-entity-handler.ts +++ b/src/controllers/handlers/deploy-entity-handler.ts @@ -1,9 +1,8 @@ -import { Entity } from '@dcl/schemas' import { IHttpServerComponent } from '@well-known-components/interfaces' +import { InvalidRequestError } from '@dcl/platform-server-commons' import { FormDataContext } from '../../logic/multipart' import { HandlerContextWithPath } from '../../types' -import { extractAuthChain } from '../../logic/extract-auth-chain' -import { InvalidRequestError } from '@dcl/platform-server-commons' +import { extractFromContext } from '../../logic/extract-deployment-info' export function requireString(val: string | null | undefined): string { if (typeof val !== 'string') throw new Error('A string was expected') @@ -13,22 +12,7 @@ export function requireString(val: string | null | undefined): string { export async function deployEntity( ctx: FormDataContext & HandlerContextWithPath<'config' | 'entityDeployer' | 'storage' | 'validator', '/entities'> ): Promise { - const entityId = requireString(ctx.formData.fields.entityId.value) - const authChain = extractAuthChain(ctx) - - const entityRaw = ctx.formData.files[entityId].value.toString() - const entityMetadataJson = JSON.parse(entityRaw) - - const entity: Entity = { - id: entityId, // this is not part of the published entity - timestamp: Date.now(), // this is not part of the published entity - ...entityMetadataJson - } - - const uploadedFiles: Map = new Map() - for (const filesKey in ctx.formData.files) { - uploadedFiles.set(filesKey, ctx.formData.files[filesKey].value) - } + const { authChain, entity, entityRaw, uploadedFiles } = extractFromContext(ctx) const contentHashesInStorage = await ctx.components.storage.existMultiple( Array.from(new Set(entity.content!.map(($) => $.hash))) @@ -52,7 +36,7 @@ export async function deployEntity( entity, contentHashesInStorage, uploadedFiles, - entityRaw, + entityRaw.toString(), authChain ) diff --git a/src/controllers/handlers/deploy-v2-handlers.ts b/src/controllers/handlers/deploy-v2-handlers.ts index b1c99568..6193ea14 100644 --- a/src/controllers/handlers/deploy-v2-handlers.ts +++ b/src/controllers/handlers/deploy-v2-handlers.ts @@ -1,9 +1,8 @@ import { IHttpServerComponent } from '@well-known-components/interfaces' import { HandlerContextWithPath } from '../../types' -import { AuthChain } from '@dcl/schemas' import { InvalidRequestError } from '@dcl/platform-server-commons' -import { Authenticator } from '@dcl/crypto' -import { StartDeploymentBody } from '../../adapters/deployment-v2-manager' +import { FormDataContext } from '../../logic/multipart' +import { extractFromContext } from '../../logic/extract-deployment-info' export function requireString(val: string | null | undefined): string { if (typeof val !== 'string') throw new Error('A string was expected') @@ -11,26 +10,41 @@ export function requireString(val: string | null | undefined): string { } export async function startDeployEntity( - ctx: HandlerContextWithPath<'config' | 'deploymentV2Manager' | 'storage' | 'validator', '/v2/entities/:entityId'> + ctx: FormDataContext & + HandlerContextWithPath<'config' | 'deploymentV2Manager' | 'storage' | 'preDeploymentValidator', '/v2/entities'> ): Promise { - const entityId = await ctx.params.entityId - const body: StartDeploymentBody = await ctx.request.json() - const authChain: AuthChain = body.authChain - console.log('entityId', entityId, 'authChain', authChain, 'files', body.files) + const { authChain, entity, entityRaw, uploadedFiles } = extractFromContext(ctx) - if (!AuthChain.validate(authChain)) { - throw new InvalidRequestError('Invalid authChain received') - } - if (!(await Authenticator.validateSignature(entityId, authChain, null, 10))) { - throw new InvalidRequestError('Invalid signature') + const contentHashesInStorage = await ctx.components.storage.existMultiple( + Array.from(new Set(entity.content!.map(($) => $.hash))) + ) + + const fileSizesManifest = JSON.parse(ctx.formData.fields.fileSizesManifest.value.toString()) + + // run all validations about the deployment + const validationResult = await ctx.components.preDeploymentValidator.validate({ + entity, + files: uploadedFiles, + authChain, + contentHashesInStorage, + fileSizesManifest + }) + if (!validationResult.ok()) { + throw new InvalidRequestError(`Deployment failed: ${validationResult.errors.join(', ')}`) } - await ctx.components.deploymentV2Manager.initDeployment(entityId, authChain, body.files) + const ongoingDeploymentData = await ctx.components.deploymentV2Manager.initDeployment( + entity.id, + entityRaw, + authChain, + fileSizesManifest + ) return { status: 200, body: { - creationTimestamp: Date.now() + availableFiles: ongoingDeploymentData.availableFiles, + missingFiles: ongoingDeploymentData.missingFiles } } } @@ -56,7 +70,9 @@ export async function finishDeployEntity( '/v2/entities/:entityId' > ): Promise { - const message = await ctx.components.deploymentV2Manager.completeDeployment(ctx.params.entityId) + const baseUrl = (await ctx.components.config.getString('HTTP_BASE_URL')) || `https://${ctx.url.host}` + + const message = await ctx.components.deploymentV2Manager.completeDeployment(baseUrl, ctx.params.entityId) return { status: 204, body: { diff --git a/src/controllers/routes.ts b/src/controllers/routes.ts index 42ef8116..abb0bbcd 100644 --- a/src/controllers/routes.ts +++ b/src/controllers/routes.ts @@ -1,6 +1,6 @@ import { Router } from '@well-known-components/http-server' import { multipartParserWrapper } from '../logic/multipart' -import { GlobalContext } from '../types' +import { GlobalContext, HandlerContextWithPath } from '../types' import { availableContentHandler, getContentFile, headContentFile } from './handlers/content-file-handler' import { deployEntity } from './handlers/deploy-entity-handler' import { worldAboutHandler } from './handlers/world-about-handler' @@ -24,11 +24,29 @@ import { reprocessABHandler } from './handlers/reprocess-ab-handler' import { garbageCollectionHandler } from './handlers/garbage-collection' import { getContributableDomainsHandler } from './handlers/contributor-handler' import { deployFile, finishDeployEntity, startDeployEntity } from './handlers/deploy-v2-handlers' +import { IHttpServerComponent } from '@well-known-components/interfaces' +import util from 'util' export async function setupRouter(globalContext: GlobalContext): Promise> { const router = new Router() router.use(errorHandler) + const logRequestMiddleware = async function logger( + ctx: HandlerContextWithPath, + next: () => Promise + ) { + const headers: Record = {} + + for (const [header, value] of ctx.request.headers) { + headers[header] = value + } + + console.log('Test server got request:\n', ctx.request.method, ctx.url.toString(), JSON.stringify(headers, null, 2)) + const response = await next() + console.log('Test server will send response:\n' + util.inspect(response, false, 30)) + return response + } + const signedFetchMiddleware = wellKnownComponents({ fetcher: globalContext.components.fetch, optional: false, @@ -42,12 +60,12 @@ export async function setupRouter(globalContext: GlobalContext): Promise = new Map() + for (const filesKey in formData.files) { + uploadedFiles.set(filesKey, formData.files[filesKey].value) + } + + return { + entity, + entityRaw, + authChain, + uploadedFiles + } +} diff --git a/src/logic/multipart.ts b/src/logic/multipart.ts index a1742b5e..0798653e 100644 --- a/src/logic/multipart.ts +++ b/src/logic/multipart.ts @@ -4,7 +4,7 @@ import { IHttpServerComponent } from '@well-known-components/interfaces' import busboy, { FieldInfo, FileInfo } from 'busboy' import { Readable } from 'stream' -export type FormDataContext = IHttpServerComponent.DefaultContext & { +export type FormDataContext = { formData: { fields: Record< string, diff --git a/src/logic/validations/scene.ts b/src/logic/validations/scene.ts index 0b74e9cf..9990b9d7 100644 --- a/src/logic/validations/scene.ts +++ b/src/logic/validations/scene.ts @@ -144,6 +144,54 @@ export function createValidateSize(components: Pick) { + return async (deployment: DeploymentToValidate): Promise => { + const calculateDeploymentSize = async ( + entity: Entity, + files: Map, + fileSizesManifest: Record + ): Promise => { + let totalSize = 0 + for (const hash of new Set(entity.content?.map((item) => item.hash) ?? [])) { + const uploadedFile = files.get(hash) + if (uploadedFile) { + totalSize += uploadedFile.byteLength + } else { + const contentSize = fileSizesManifest[hash] || 0 + totalSize += contentSize + } + } + return totalSize + } + + if (!deployment.fileSizesManifest) { + return createValidationResult(['Missing fileSizesManifest']) + } + + const sceneJson = JSON.parse(deployment.files.get(deployment.entity.id)!.toString()) + const worldName = sceneJson.metadata.worldConfiguration.name + const maxTotalSizeInBytes = await components.limitsManager.getMaxAllowedSizeInBytesFor(worldName || '') + + const errors: string[] = [] + try { + const deploymentSize = await calculateDeploymentSize( + deployment.entity, + deployment.files, + deployment.fileSizesManifest + ) + if (deploymentSize > maxTotalSizeInBytes) { + errors.push( + `The deployment is too big. The maximum total size allowed is ${maxTotalSizeInBytes} bytes for scenes. You can upload up to ${maxTotalSizeInBytes} bytes but you tried to upload ${deploymentSize}.` + ) + } + } catch (e: any) { + errors.push(e.message) + } + + return createValidationResult(errors) + } +} + export function createValidateSdkVersion(components: Pick) { return async (deployment: DeploymentToValidate): Promise => { const sceneJson = JSON.parse(deployment.files.get(deployment.entity.id)!.toString()) diff --git a/src/logic/validations/validator.ts b/src/logic/validations/validator.ts index fe40cc5d..dad29a53 100644 --- a/src/logic/validations/validator.ts +++ b/src/logic/validations/validator.ts @@ -10,6 +10,7 @@ import { validateSupportedEntityType } from './common' import { + createPreDeploymentValidateSize, createValidateBannedNames, createValidateDeploymentPermission, createValidateSceneDimensions, @@ -58,6 +59,40 @@ export function createValidateFns(components: ValidatorComponents): Validation[] ] } +export function createPreDeploymentValidateFns(components: ValidatorComponents): Validation[] { + return [ + // Common validations to all entity types + validateAll([ + validateEntityId, + validateBaseEntity, + validateAuthChain, + validateSigner, + validateSignature, + createValidateDeploymentTtl(components), + validateSupportedEntityType + ]), + + // Scene entity validations + validateIfTypeMatches( + EntityType.SCENE, + validateAll([ + validateSceneEntity, + validateDeprecatedConfig, + createValidateSceneDimensions(components), + validateMiniMapImages, + validateSkyboxTextures, + validateThumbnail, + createValidateBannedNames(components), + // validateSdkVersion(components) TODO re-enable (and test) once SDK7 is ready + createPreDeploymentValidateSize(components), + createValidateDeploymentPermission(components) // Slow + ]) + ) + + // Other entity validations will go here ... + ] +} + export const createValidator = (components: ValidatorComponents): Validator => ({ async validate(deployment: DeploymentToValidate): Promise { const validations = createValidateFns(components) @@ -71,3 +106,17 @@ export const createValidator = (components: ValidatorComponents): Validator => ( return OK } }) + +export const createPreDeploymentValidator = (components: ValidatorComponents): Validator => ({ + async validate(deployment: DeploymentToValidate): Promise { + const validations = createPreDeploymentValidateFns(components) + for (const validation of validations) { + const result = await validation(deployment) + if (!result.ok()) { + return result + } + } + + return OK + } +}) diff --git a/src/types.ts b/src/types.ts index ac4b74d5..9bc4b9cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,7 @@ export type DeploymentToValidate = { files: Map authChain: AuthChain contentHashesInStorage: Map + fileSizesManifest?: Record } export type WorldRuntimeMetadata = { @@ -280,6 +281,7 @@ export type BaseComponents = { namePermissionChecker: IWorldNamePermissionChecker notificationService: INotificationService permissionsManager: IPermissionsManager + preDeploymentValidator: Validator server: IHttpServerComponent snsClient: SnsClient status: IStatusComponent @@ -325,11 +327,6 @@ export type HandlerContextWithPath< Path > -export interface ErrorResponse { - error: string - message: string -} - type WhitelistEntry = { max_parcels?: number max_size_in_mb?: number From 9539a7a43cc4bff19244fe309e2824f59d9d4154 Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Fri, 14 Jun 2024 16:55:29 -0300 Subject: [PATCH 4/6] wip --- package.json | 2 +- yarn.lock | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index aff6db54..3238fa27 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@types/bcrypt": "^5.0.2", "@types/node": "^20.11.10", "@well-known-components/test-helpers": "^1.5.6", - "dcl-catalyst-client": "^21.7.0", + "dcl-catalyst-client": "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz", "typescript": "^5.3.3" }, "prettier": { diff --git a/yarn.lock b/yarn.lock index fca89a56..f2158769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2572,6 +2572,19 @@ dcl-catalyst-client@^21.7.0: cross-fetch "^3.1.5" form-data "^4.0.0" +"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz": + version "21.7.1-9517693754.commit-34d6bdc" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz#86be52bf40ba11b714217caee2c4ddfab288a1ed" + dependencies: + "@dcl/catalyst-contracts" "^4.4.0" + "@dcl/crypto" "^3.4.0" + "@dcl/hashing" "^3.0.0" + "@dcl/schemas" "^11.5.0" + "@well-known-components/fetch-component" "^2.0.0" + cookie "^0.5.0" + cross-fetch "^3.1.5" + form-data "^4.0.0" + debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" From fcef9c4ccc1186ad025fa306e512645407274dd7 Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Fri, 14 Jun 2024 17:05:32 -0300 Subject: [PATCH 5/6] wip --- package.json | 2 +- yarn.lock | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 3238fa27..5caf6d1b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@types/bcrypt": "^5.0.2", "@types/node": "^20.11.10", "@well-known-components/test-helpers": "^1.5.6", - "dcl-catalyst-client": "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz", + "dcl-catalyst-client": "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9521698381.commit-b45e84a.tgz", "typescript": "^5.3.3" }, "prettier": { diff --git a/yarn.lock b/yarn.lock index f2158769..8e0c4558 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2558,10 +2558,9 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -dcl-catalyst-client@^21.7.0: - version "21.7.0" - resolved "https://registry.yarnpkg.com/dcl-catalyst-client/-/dcl-catalyst-client-21.7.0.tgz#dbf33dff0bcf382c8383f359dbfaf7f85011af25" - integrity sha512-10NyeYrKSh7yM5y7suLfoDeVAM9xknlvlxDBof1lJiuPYPzj5Aee8kaEDfXzVWTpfI7ssvSXhBlGVRvyd0RcJA== +"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz": + version "21.7.1-9517693754.commit-34d6bdc" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz#86be52bf40ba11b714217caee2c4ddfab288a1ed" dependencies: "@dcl/catalyst-contracts" "^4.4.0" "@dcl/crypto" "^3.4.0" @@ -2572,9 +2571,9 @@ dcl-catalyst-client@^21.7.0: cross-fetch "^3.1.5" form-data "^4.0.0" -"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz": - version "21.7.1-9517693754.commit-34d6bdc" - resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz#86be52bf40ba11b714217caee2c4ddfab288a1ed" +"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9521698381.commit-b45e84a.tgz": + version "21.7.1-9521698381.commit-b45e84a" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9521698381.commit-b45e84a.tgz#1f1f5c935b17d508154ef387e8eb0ab6d51a05c6" dependencies: "@dcl/catalyst-contracts" "^4.4.0" "@dcl/crypto" "^3.4.0" From d2f74eee9fbc58f3a8bef4615eb988943a7ded4d Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Wed, 28 Aug 2024 12:26:44 -0300 Subject: [PATCH 6/6] add todos --- package.json | 2 +- src/adapters/deployment-v2-manager.ts | 114 ++++++++++-------- .../handlers/deploy-v2-handlers.ts | 4 +- yarn.lock | 6 +- 4 files changed, 70 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 5caf6d1b..e5a700bf 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@types/bcrypt": "^5.0.2", "@types/node": "^20.11.10", "@well-known-components/test-helpers": "^1.5.6", - "dcl-catalyst-client": "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9521698381.commit-b45e84a.tgz", + "dcl-catalyst-client": "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-10599703790.commit-1aa8621.tgz", "typescript": "^5.3.3" }, "prettier": { diff --git a/src/adapters/deployment-v2-manager.ts b/src/adapters/deployment-v2-manager.ts index b3d46282..eebbbb85 100644 --- a/src/adapters/deployment-v2-manager.ts +++ b/src/adapters/deployment-v2-manager.ts @@ -36,6 +36,8 @@ export function createDeploymentV2Manager( // Check what files are already in storage and temporary storage and return the result const results = Array.from((await storage.existMultiple(Object.keys(files))).entries()) + // TODO we could check the local temp folder to avoid the client uploading the same file multiple times + const ongoingDeploymentMetadata = { authChain, files, @@ -43,8 +45,10 @@ export function createDeploymentV2Manager( missingFiles: results.filter(([_, available]) => !available).map(([cid, _]) => cid) } + // TODO we could store this in local file system of the ECS ongoingDeploymentsRecord[entityId] = ongoingDeploymentMetadata + // TODO same tempFiles[entityId] = entityRaw return ongoingDeploymentMetadata @@ -55,6 +59,9 @@ export function createDeploymentV2Manager( if (!ongoingDeploymentsRecordElement) { throw new InvalidRequestError(`Deployment for entity ${entityId} not found`) } + + // TODO we could check if the ongoing deployment is too old, and reject the file + const computedFileHash = await hashV1(file) if (computedFileHash === entityId || fileHash === computedFileHash) { if (!ongoingDeploymentsRecordElement.missingFiles.includes(fileHash)) { @@ -69,71 +76,78 @@ export function createDeploymentV2Manager( } } + // TODO store in temp files folder tempFiles[fileHash] = file logger.info(`Received file ${fileHash} for entity ${entityId}`) } async function completeDeployment(baseUrl: string, entityId: string): Promise { - const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] - if (!ongoingDeploymentsRecordElement) { - throw new Error(`Deployment for entity ${entityId} not found`) - } + try { + const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] + if (!ongoingDeploymentsRecordElement) { + throw new Error(`Deployment for entity ${entityId} not found`) + } - logger.info(`Completing deployment for entity ${entityId}`) + // TODO we could check if the ongoing deployment is too old, and reject the file (deployment TTL) - const authChain = ongoingDeploymentsRecordElement.authChain - const entityRaw = tempFiles[entityId].toString() - if (!entityRaw) { - throw new Error(`Entity file not found in deployment for entity ${entityId}.`) - } + logger.info(`Completing deployment for entity ${entityId}`) - const entityMetadataJson = JSON.parse(entityRaw.toString()) + const authChain = ongoingDeploymentsRecordElement.authChain + const entityRaw = tempFiles[entityId].toString() + if (!entityRaw) { + throw new Error(`Entity file not found in deployment for entity ${entityId}.`) + } - const entity: Entity = { - id: entityId, // this is not part of the published entity - timestamp: Date.now(), // this is not part of the published entity - ...entityMetadataJson - } + const entityMetadataJson = JSON.parse(entityRaw.toString()) - const uploadedFiles: Map = new Map() - for (const fileHash of Object.keys(ongoingDeploymentsRecordElement.files)) { - if (tempFiles[fileHash]) { - uploadedFiles.set(fileHash, tempFiles[fileHash]) + const entity: Entity = { + id: entityId, // this is not part of the published entity + timestamp: Date.now(), // this is not part of the published entity + ...entityMetadataJson } - } - - const contentHashesInStorage = await storage.existMultiple(Array.from(new Set(entity.content!.map(($) => $.hash)))) - // run all validations about the deployment - const validationResult = await validator.validate({ - entity, - files: uploadedFiles, - authChain, - contentHashesInStorage - }) - if (!validationResult.ok()) { - throw new InvalidRequestError(`Deployment failed: ${validationResult.errors.join(', ')}`) - } + const uploadedFiles: Map = new Map() + for (const fileHash of Object.keys(ongoingDeploymentsRecordElement.files)) { + if (tempFiles[fileHash]) { + uploadedFiles.set(fileHash, tempFiles[fileHash]) + } + } - // Store the entity - const deploymentResult = await entityDeployer.deployEntity( - baseUrl, - entity, - contentHashesInStorage, - uploadedFiles, - entityRaw, - authChain - ) - - // Clean up temporary files - for (const fileHash in ongoingDeploymentsRecordElement.files) { - delete tempFiles[fileHash] - } + const contentHashesInStorage = await storage.existMultiple( + Array.from(new Set(entity.content!.map(($) => $.hash))) + ) + + // run all validations about the deployment + const validationResult = await validator.validate({ + entity, + files: uploadedFiles, + authChain, + contentHashesInStorage + }) + if (!validationResult.ok()) { + throw new InvalidRequestError(`Deployment failed: ${validationResult.errors.join(', ')}`) + } - // Clean up ongoing deployments - delete ongoingDeploymentsRecord[entityId] + // Store the entity + return await entityDeployer.deployEntity( + baseUrl, + entity, + contentHashesInStorage, + uploadedFiles, + entityRaw, + authChain + ) + } finally { + // Clean up temporary files + const ongoingDeploymentsRecordElement = ongoingDeploymentsRecord[entityId] + + for (const fileHash in ongoingDeploymentsRecordElement.files) { + delete tempFiles[fileHash] + } - return deploymentResult + // Clean up ongoing deployments + delete ongoingDeploymentsRecord[entityId] + } } return { diff --git a/src/controllers/handlers/deploy-v2-handlers.ts b/src/controllers/handlers/deploy-v2-handlers.ts index 6193ea14..f40db6ba 100644 --- a/src/controllers/handlers/deploy-v2-handlers.ts +++ b/src/controllers/handlers/deploy-v2-handlers.ts @@ -52,8 +52,8 @@ export async function startDeployEntity( export async function deployFile( ctx: HandlerContextWithPath<'deploymentV2Manager', '/v2/entities/:entityId/files/:fileHash'> ): Promise { - const entityId = await ctx.params.entityId - const fileHash = await ctx.params.fileHash + const entityId = ctx.params.entityId + const fileHash = ctx.params.fileHash const buffer = await ctx.request.buffer() await ctx.components.deploymentV2Manager.addFileToDeployment(entityId, fileHash, buffer) diff --git a/yarn.lock b/yarn.lock index 8e0c4558..acf2f16e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2558,9 +2558,9 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz": - version "21.7.1-9517693754.commit-34d6bdc" - resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-9517693754.commit-34d6bdc.tgz#86be52bf40ba11b714217caee2c4ddfab288a1ed" +"dcl-catalyst-client@https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-10599703790.commit-1aa8621.tgz": + version "21.7.1-10599703790.commit-1aa8621" + resolved "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/deploy-v2/dcl-catalyst-client-21.7.1-10599703790.commit-1aa8621.tgz#3715021ce107e198aa6d718042a53e105206338c" dependencies: "@dcl/catalyst-contracts" "^4.4.0" "@dcl/crypto" "^3.4.0"