From 26ee0bca99b6d4c0d24e6ec6cb75e268ff08cfa8 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 09:09:54 +0300 Subject: [PATCH 01/10] add persistentStorage as Storage first class --- src/@types/PersistentStorage.ts | 8 +- src/@types/fileObject.ts | 10 +- src/components/c2d/compute_engine_docker.ts | 178 +++++----- src/components/core/compute/initialize.ts | 18 +- src/components/core/compute/startCompute.ts | 28 +- src/components/core/compute/utils.ts | 16 +- src/components/core/handler/ddoHandler.ts | 3 +- .../core/handler/downloadHandler.ts | 36 +- .../core/handler/fileInfoHandler.ts | 17 + .../PersistentStorageFactory.ts | 57 +-- .../PersistentStorageLocalFS.ts | 52 ++- .../persistentStorage/PersistentStorageS3.ts | 27 ++ .../storage/NodePersistentStorage.ts | 92 +++++ src/components/storage/Storage.ts | 9 +- src/components/storage/getStorageClass.ts | 10 +- src/components/storage/index.ts | 11 +- src/test/integration/compute.test.ts | 333 ++++++++++++++++++ .../integration/persistentStorage.test.ts | 194 +++++++++- 18 files changed, 919 insertions(+), 180 deletions(-) create mode 100644 src/components/storage/NodePersistentStorage.ts diff --git a/src/@types/PersistentStorage.ts b/src/@types/PersistentStorage.ts index 67b0448a2..f6b177acf 100644 --- a/src/@types/PersistentStorage.ts +++ b/src/@types/PersistentStorage.ts @@ -1,5 +1,5 @@ import type { AccessList } from './AccessList' -import type { BaseFileObject } from './fileObject.js' +export type { PersistentStorageObject } from './fileObject.js' export type PersistentStorageType = 'localfs' | 's3' export interface PersistentStorageLocalFSOptions { @@ -33,9 +33,3 @@ export interface DockerMountObject { Target: string ReadOnly: boolean } - -export interface PersistentStorageObject extends BaseFileObject { - type: 'nodePersistentStorage' - bucketId: string - fileName: string -} diff --git a/src/@types/fileObject.ts b/src/@types/fileObject.ts index 23803bc1f..0da30fa62 100644 --- a/src/@types/fileObject.ts +++ b/src/@types/fileObject.ts @@ -49,12 +49,19 @@ export interface FtpFileObject extends BaseFileObject { url: string } +export interface PersistentStorageObject extends BaseFileObject { + type: 'nodePersistentStorage' + bucketId: string + fileName: string +} + export type StorageObject = | UrlFileObject | IpfsFileObject | ArweaveFileObject | S3FileObject | FtpFileObject + | PersistentStorageObject export interface StorageReadable { stream: Readable @@ -68,7 +75,8 @@ export enum FileObjectType { IPFS = 'ipfs', ARWEAVE = 'arweave', S3 = 's3', - FTP = 'ftp' + FTP = 'ftp', + NODE_PERSISTENT_STORAGE = 'nodePersistentStorage' } export interface FileInfoRequest { diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 95f188e9c..4f3ca127d 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -59,7 +59,7 @@ import { ValidateParams } from '../httpRoutes/validateCommands.js' import { Service } from '@oceanprotocol/ddo-js' import { getOceanTokenAddressForChain } from '../../utils/address.js' import { dockerRegistryAuth, OceanNodeConfig } from '../../@types/OceanNode.js' -import { EncryptMethod } from '../../@types/fileObject.js' +import { BaseFileObject, EncryptMethod } from '../../@types/fileObject.js' import { getAddress, ZeroAddress } from 'ethers' import { AccessList } from '../../@types/AccessList.js' @@ -1962,10 +1962,16 @@ export class C2DEngineDocker extends C2DEngine { // persistent Storage: bind-mount bucket files into the job container (localfs backend) for (const i in job.assets) { const asset = job.assets[i] - if (!asset.fileObject || asset.fileObject.type !== 'nodePersistentStorage') { + // resolve the effective file object (plaintext, encrypted, or via documentId/serviceId) + const resolved = await this.resolveComputeFileObject(asset) + if (!resolved || resolved.type !== 'nodePersistentStorage') { + // non persistent-storage assets are downloaded later, during uploadData continue } - const fo = asset.fileObject as { bucketId?: string; fileName?: string } + // persist the resolved (plaintext) persistent-storage file object back onto the job + // so the uploadData phase skips it without having to decrypt/resolve again + asset.fileObject = resolved + const fo = resolved as unknown as { bucketId?: string; fileName?: string } if (!fo.bucketId || !fo.fileName) { CORE_LOGGER.error( `Job ${job.jobId} asset ${i}: nodePersistentStorage requires bucketId and fileName` @@ -2844,6 +2850,50 @@ export class C2DEngineDocker extends C2DEngine { return filesObject } + /** + * Resolves the effective (decrypted) file object for a compute asset or algorithm. + * Handles the three ways a file object can arrive: + * 1. plaintext file object (already has a `type`) + * 2. encrypted file object (no `type`) -> decrypt it + * 3. no file object, only `documentId` + `serviceId` -> resolve the DDO and decrypt `service.files` + * Returns null if nothing could be resolved. + */ + private async resolveComputeFileObject( + item: ComputeAsset | ComputeAlgorithm + ): Promise { + try { + if (item.fileObject) { + // plaintext: type is directly available + if ((item.fileObject as BaseFileObject).type) { + return item.fileObject as BaseFileObject + } + // encrypted: decrypt to reveal the type + return await decryptFilesObject(item.fileObject) + } + // no file object: try documentId + serviceId + const { documentId, serviceId } = item + if (documentId && serviceId) { + const ddo = await new FindDdoHandler(OceanNode.getInstance()).findAndFormatDdo( + documentId + ) + if (!ddo) { + return null + } + const service: Service = AssetUtils.getServiceById(ddo, serviceId) + if (!service) { + return null + } + return await decryptFilesObject(service.files) + } + return null + } catch (e) { + CORE_LOGGER.error( + `Unable to resolve compute file object: ${e instanceof Error ? e.message : String(e)}` + ) + return null + } + } + private async uploadData( job: DBComputeJob ): Promise<{ status: C2DStatusNumber; statusText: C2DStatusText }> { @@ -2872,108 +2922,37 @@ export class C2DEngineDocker extends C2DEngine { appendFileSync(configLogPath, `Writing raw algo code to ${fullAlgoPath}\n`) writeFileSync(fullAlgoPath, job.algorithm.meta.rawcode) } else { - // do we have a files object? - if (job.algorithm.fileObject) { - // is it unencrypted? - if (job.algorithm.fileObject.type) { - // we can get the storage directly - try { - storage = Storage.getStorageClass(job.algorithm.fileObject, config) - } catch (e) { - CORE_LOGGER.error(`Unable to get storage class for algorithm: ${e.message}`) - appendFileSync( - configLogPath, - `Unable to get storage class for algorithm: ${e.message}\n` - ) - return { - status: C2DStatusNumber.AlgorithmProvisioningFailed, - statusText: C2DStatusText.AlgorithmProvisioningFailed - } - } - } else { - // ok, maybe we have this encrypted instead - CORE_LOGGER.info( - 'algorithm file object seems to be encrypted, checking it...' - ) - // 1. Decrypt the files object - try { - const decryptedFileObject = await decryptFilesObject( - job.algorithm.fileObject - ) - storage = Storage.getStorageClass(decryptedFileObject, config) - } catch (e) { - CORE_LOGGER.error(`Unable to decrypt algorithm files object: ${e.message}`) - appendFileSync( - configLogPath, - `Unable to decrypt algorithm files object: ${e.message}\n` - ) - return { - status: C2DStatusNumber.AlgorithmProvisioningFailed, - statusText: C2DStatusText.AlgorithmProvisioningFailed - } - } - } - } else { - // no files object, try to get information from documentId and serviceId + // resolve the effective algorithm file object (plaintext, encrypted, or via + // documentId/serviceId). All types — including nodePersistentStorage — are + // streamed uniformly through the Storage class (job.owner enforces the bucket + // ACL for persistent storage). + const resolved = await this.resolveComputeFileObject(job.algorithm) + if (!resolved) { CORE_LOGGER.info( - 'algorithm file object seems to be missing, checking "serviceId" and "documentId"...' + 'Could not extract any files object from the compute algorithm, skipping...' ) - const { serviceId, documentId } = job.algorithm appendFileSync( configLogPath, - `Using ${documentId} and serviceId ${serviceId} to get algorithm files.\n` + 'Could not extract any files object from the compute algorithm, skipping...\n' ) - // we can get it from this info - if (serviceId && documentId) { - const algoDdo = await new FindDdoHandler( - OceanNode.getInstance() - ).findAndFormatDdo(documentId) - // 1. Get the service - const service: Service = AssetUtils.getServiceById(algoDdo, serviceId) - if (!service) { - CORE_LOGGER.error( - `Could not find service with ID ${serviceId} in DDO ${documentId}` - ) - appendFileSync( - configLogPath, - `Could not find service with ID ${serviceId} in DDO ${documentId}\n` - ) - return { - status: C2DStatusNumber.AlgorithmProvisioningFailed, - statusText: C2DStatusText.AlgorithmProvisioningFailed - } - } - try { - // 2. Decrypt the files object - const decryptedFileObject = await decryptFilesObject(service.files) - storage = Storage.getStorageClass(decryptedFileObject, config) - } catch (e) { - CORE_LOGGER.error(`Unable to decrypt algorithm files object: ${e.message}`) - appendFileSync( - configLogPath, - `Unable to decrypt algorithm files object: ${e.message}\n` - ) - return { - status: C2DStatusNumber.AlgorithmProvisioningFailed, - statusText: C2DStatusText.AlgorithmProvisioningFailed - } + } else { + try { + storage = Storage.getStorageClass(resolved, config, job.owner) + } catch (e) { + CORE_LOGGER.error(`Unable to get storage class for algorithm: ${e.message}`) + appendFileSync( + configLogPath, + `Unable to get storage class for algorithm: ${e.message}\n` + ) + return { + status: C2DStatusNumber.AlgorithmProvisioningFailed, + statusText: C2DStatusText.AlgorithmProvisioningFailed } } - } - - if (storage) { await pipeline( (await storage.getReadableStream()).stream, createWriteStream(fullAlgoPath) ) - } else { - CORE_LOGGER.info( - 'Could not extract any files object from the compute algorithm, skipping...' - ) - appendFileSync( - configLogPath, - 'Could not extract any files object from the compute algorithm, skipping...\n' - ) } } } catch (e) { @@ -3001,7 +2980,7 @@ export class C2DEngineDocker extends C2DEngine { try { if (asset.fileObject.type) { if (asset.fileObject.type === 'nodePersistentStorage') { - // local storage is handled later, when we start the container and create the binds + // persistent storage is handled during ConfiguringVolumes via bind mounts continue } storage = Storage.getStorageClass(asset.fileObject, config) @@ -3009,6 +2988,11 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.info('asset file object seems to be encrypted, checking it...') // get the encrypted bytes let filesObject: any = await decryptFilesObject(asset.fileObject) + // persistent storage assets are bind-mounted during ConfiguringVolumes; + // skip download here even if the resolved object wasn't written back + if (filesObject?.type === 'nodePersistentStorage') { + continue + } filesObject = await this.addUserDataToFilesObject(filesObject, asset.userdata) storage = Storage.getStorageClass(filesObject, config) } @@ -3045,6 +3029,10 @@ export class C2DEngineDocker extends C2DEngine { const service: Service = AssetUtils.getServiceById(ddo, serviceId) // 3. Decrypt the url let decryptedFileObject = await decryptFilesObject(service.files) + // persistent storage assets are bind-mounted during ConfiguringVolumes + if (decryptedFileObject?.type === 'nodePersistentStorage') { + continue + } decryptedFileObject = await this.addUserDataToFilesObject( decryptedFileObject, asset.userdata diff --git a/src/components/core/compute/initialize.ts b/src/components/core/compute/initialize.ts index 99374b785..b35e2f27a 100644 --- a/src/components/core/compute/initialize.ts +++ b/src/components/core/compute/initialize.ts @@ -39,10 +39,7 @@ import { validateAlgoForDataset, validateOutput } from './utils.js' -import { - ensureConsumerAllowedForPersistentStorageLocalfsFileObject, - rejectPersistentStorageFileObjectOnAlgorithm -} from '../../persistentStorage/PersistentStorageFactory.js' +import { ensureConsumerAllowedForPersistentStorageLocalfsFileObject } from '../../persistentStorage/PersistentStorageFactory.js' export class ComputeInitializeHandler extends CommandHandler { validate(command: ComputeInitializeCommand): ValidateParams { @@ -107,7 +104,8 @@ export class ComputeInitializeHandler extends CommandHandler { task.algorithm.documentId, task.algorithm.serviceId, node, - config + config, + task.consumerAddress ) const isRawCodeAlgorithm = task.algorithm.meta?.rawcode @@ -224,17 +222,11 @@ export class ComputeInitializeHandler extends CommandHandler { if (isValidOutput.status.httpStatus !== 200) { return isValidOutput } - const algoPersistentStorageBan = rejectPersistentStorageFileObjectOnAlgorithm( - task.algorithm.fileObject - ) - if (algoPersistentStorageBan) { - return algoPersistentStorageBan - } - for (const dataset of task.datasets) { + for (const elem of [task.algorithm, ...task.datasets]) { const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( node, task.consumerAddress, - dataset.fileObject + elem.fileObject ) if (psAccess) { return psAccess diff --git a/src/components/core/compute/startCompute.ts b/src/components/core/compute/startCompute.ts index 69a16bf1e..aa04e6ca0 100644 --- a/src/components/core/compute/startCompute.ts +++ b/src/components/core/compute/startCompute.ts @@ -45,10 +45,7 @@ import { getNonceAsNumber } from '../utils/nonceHandler.js' import { PolicyServer } from '../../policyServer/index.js' import { checkCredentials } from '../../../utils/credentials.js' import { checkAddressOnAccessList } from '../../../utils/accessList.js' -import { - ensureConsumerAllowedForPersistentStorageLocalfsFileObject, - rejectPersistentStorageFileObjectOnAlgorithm -} from '../../persistentStorage/PersistentStorageFactory.js' +import { ensureConsumerAllowedForPersistentStorageLocalfsFileObject } from '../../persistentStorage/PersistentStorageFactory.js' export class CommonComputeHandler extends CommandHandler { validate(command: PaidComputeStartCommand): ValidateParams { @@ -214,7 +211,8 @@ export class PaidComputeStartHandler extends CommonComputeHandler { task.algorithm.documentId, task.algorithm.serviceId, node, - config + config, + task.consumerAddress ) const isRawCodeAlgorithm = task.algorithm.meta?.rawcode @@ -233,17 +231,11 @@ export class PaidComputeStartHandler extends CommonComputeHandler { } } const policyServer = new PolicyServer() - const algoPersistentStorageBan = rejectPersistentStorageFileObjectOnAlgorithm( - task.algorithm.fileObject - ) - if (algoPersistentStorageBan) { - return algoPersistentStorageBan - } - for (const dataset of task.datasets) { + for (const elem of [task.algorithm, ...task.datasets]) { const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( node, task.consumerAddress, - dataset.fileObject + elem.fileObject ) if (psAccess) { return psAccess @@ -787,17 +779,11 @@ export class FreeComputeStartHandler extends CommonComputeHandler { return isValidOutput } const policyServer = new PolicyServer() - const algoPersistentStorageBanFree = rejectPersistentStorageFileObjectOnAlgorithm( - task.algorithm.fileObject - ) - if (algoPersistentStorageBanFree) { - return algoPersistentStorageBanFree - } - for (const dataset of task.datasets) { + for (const elem of [task.algorithm, ...task.datasets]) { const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( thisNode, task.consumerAddress, - dataset.fileObject + elem.fileObject ) if (psAccess) { return psAccess diff --git a/src/components/core/compute/utils.ts b/src/components/core/compute/utils.ts index 5f92fda04..0f88c0125 100644 --- a/src/components/core/compute/utils.ts +++ b/src/components/core/compute/utils.ts @@ -26,7 +26,8 @@ export async function getAlgoChecksums( algoDID: string | undefined, algoServiceId: string | undefined, oceanNode: OceanNode, - config: OceanNodeConfig + config: OceanNodeConfig, + consumerAddress?: string ): Promise { const checksums: AlgoChecksums = { files: '', @@ -45,7 +46,18 @@ export async function getAlgoChecksums( } const fileArray = await getFile(algoDDO, algoServiceId, oceanNode) for (const file of fileArray) { - const storage = Storage.getStorageClass(file as StorageObject, config) + // persistent storage checksums require a consumerAddress (ACL); guard so a + // missing consumer errors instead of silently producing an empty checksum + if ((file as any)?.type === 'nodePersistentStorage' && !consumerAddress) { + throw new Error( + 'Unable to compute checksum for persistent storage algorithm file: missing consumerAddress' + ) + } + const storage = Storage.getStorageClass( + file as StorageObject, + config, + consumerAddress + ) const fileInfo = await storage.fetchSpecificFileMetadata( file as StorageObject, true // force checksum diff --git a/src/components/core/handler/ddoHandler.ts b/src/components/core/handler/ddoHandler.ts index 7c70a513b..eefb1f4da 100644 --- a/src/components/core/handler/ddoHandler.ts +++ b/src/components/core/handler/ddoHandler.ts @@ -776,7 +776,8 @@ export class ValidateDDOHandler extends CommandHandler { try { const ddoInstance = DDOManager.getDDOClass(task.ddo) const validation = await ddoInstance.validate() - + console.log(validation) + console.log(JSON.stringify(validation)) if (validation[0] === false) { CORE_LOGGER.logMessageWithEmoji( `Validation failed with error: ${validation[1]}`, diff --git a/src/components/core/handler/downloadHandler.ts b/src/components/core/handler/downloadHandler.ts index 02ff4a14b..0597707cd 100644 --- a/src/components/core/handler/downloadHandler.ts +++ b/src/components/core/handler/downloadHandler.ts @@ -19,7 +19,7 @@ import { checkCredentials } from '../../../utils/credentials.js' import { CORE_LOGGER } from '../../../utils/logging/common.js' import { OceanNode } from '../../../OceanNode.js' import { DownloadCommand, DownloadURLCommand } from '../../../@types/commands.js' -import { EncryptMethod } from '../../../@types/fileObject.js' +import { EncryptMethod, FileObjectType } from '../../../@types/fileObject.js' import { validateCommandParameters, @@ -56,14 +56,30 @@ export function isOrderingAllowedForAsset(asset: Asset): OrdableAssetResponse { export async function handleDownloadUrlCommand( node: OceanNode, - task: DownloadURLCommand + task: DownloadURLCommand, + consumerAddress?: string ): Promise { const encryptFile = !!task.aes_encrypted_key CORE_LOGGER.logMessage('DownloadCommand requires file encryption? ' + encryptFile, true) const config = node.getConfig() try { + // Persistent-storage files are ACL-gated by the bucket; refuse to serve them + // unless we know the consumer (this command path has no order/credential gating). + if ( + (task.fileObject as { type?: string })?.type === + FileObjectType.NODE_PERSISTENT_STORAGE && + !consumerAddress + ) { + return { + stream: null, + status: { + httpStatus: 403, + error: 'Persistent storage files require an authenticated consumer' + } + } + } // Determine the type of storage and get a readable stream - const storage = Storage.getStorageClass(task.fileObject, config) + const storage = Storage.getStorageClass(task.fileObject, config, consumerAddress) // Validate storage configuration (checks if gateways are configured) const [isValid, validationError] = storage.validate() @@ -542,11 +558,15 @@ export class DownloadHandler extends CommandHandler { } // 8. Proceed to download the file - return await handleDownloadUrlCommand(node, { - fileObject: decriptedFileObject, - aes_encrypted_key: task.aes_encrypted_key, - command: PROTOCOL_COMMANDS.DOWNLOAD - }) + return await handleDownloadUrlCommand( + node, + { + fileObject: decriptedFileObject, + aes_encrypted_key: task.aes_encrypted_key, + command: PROTOCOL_COMMANDS.DOWNLOAD + }, + task.consumerAddress + ) } catch (e) { CORE_LOGGER.logMessage('Decryption error: ' + e, true) return { diff --git a/src/components/core/handler/fileInfoHandler.ts b/src/components/core/handler/fileInfoHandler.ts index 14649c5b8..561636b61 100644 --- a/src/components/core/handler/fileInfoHandler.ts +++ b/src/components/core/handler/fileInfoHandler.ts @@ -25,6 +25,17 @@ async function formatMetadata( name: string type: string }> { + // Persistent-storage files are ACL-gated and not exposed through fileInfo; return a + // generic entry instead of querying the backend (which would leak size/existence). + if ((file as { type?: string })?.type === FileObjectType.NODE_PERSISTENT_STORAGE) { + return { + valid: true, + contentLength: '', + contentType: 'application/octet-stream', + name: '', + type: FileObjectType.NODE_PERSISTENT_STORAGE + } + } const storage = Storage.getStorageClass(file, config) const fileInfo = await storage.fetchSpecificFileMetadata(file, false) CORE_LOGGER.logMessage( @@ -63,6 +74,12 @@ export class FileInfoHandler extends CommandHandler { 'Invalid Request: type must be one of ' + Object.values(FileObjectType).join(', ') ) } + // persistent storage files are ACL-gated and not served through fileInfo + if (command.type === FileObjectType.NODE_PERSISTENT_STORAGE) { + return buildInvalidRequestMessage( + 'Invalid Request: nodePersistentStorage files are not supported by fileInfo' + ) + } return validation } diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index b653c56f3..2adf475f7 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -186,6 +186,37 @@ export abstract class PersistentStorageFactory { consumerAddress: string ): Promise + /** + * Returns a sha256 checksum of a bucket file's contents. + * Used to compute algorithm file checksums for compute jobs that reference + * persistent storage. + */ + public abstract getFileChecksum( + bucketId: string, + fileName: string, + consumerAddress: string + ): Promise + + /** + * Stat-like metadata for a bucket file. ACL is enforced only when + * `consumerAddress` is provided (mirrors `getDockerMountObject`). + */ + public abstract getFileInfo( + bucketId: string, + fileName: string, + consumerAddress?: string + ): Promise<{ size: number; lastModified: number }> + + /** + * Returns a readable stream of a bucket file's contents. ACL is enforced only + * when `consumerAddress` is provided. Backs the NodePersistentStorage class. + */ + public abstract getReadableStream( + bucketId: string, + fileName: string, + consumerAddress?: string + ): Promise + // common functions async getBucketAccessList(bucketId: string): Promise { try { @@ -362,31 +393,7 @@ export abstract class PersistentStorageFactory { } /** - * Algorithms must not reference node persistent storage; only datasets may use - * `nodePersistentStorage` / `localfs` file objects. - */ -export function rejectPersistentStorageFileObjectOnAlgorithm( - fileObject: unknown -): P2PCommandResponse | null { - if (fileObject === null || fileObject === undefined || typeof fileObject !== 'object') { - return null - } - const fo = fileObject as { type?: string } - if (fo.type === 'nodePersistentStorage' || fo.type === 'localfs') { - return { - stream: null, - status: { - httpStatus: 400, - error: - 'Algorithms cannot use node persistent storage file objects; only datasets may reference persistent storage.' - } - } - } - return null -} - -/** - * When a compute dataset uses a node persistent-storage file (localfs backend), + * When a compute dataset or algorithm uses a node persistent-storage file (localfs backend), * ensure the consumer is on the bucket ACL before proceeding. */ export async function ensureConsumerAllowedForPersistentStorageLocalfsFileObject( diff --git a/src/components/persistentStorage/PersistentStorageLocalFS.ts b/src/components/persistentStorage/PersistentStorageLocalFS.ts index 5bf515450..2055a8e16 100644 --- a/src/components/persistentStorage/PersistentStorageLocalFS.ts +++ b/src/components/persistentStorage/PersistentStorageLocalFS.ts @@ -2,7 +2,7 @@ import fs from 'fs' import fsp from 'fs/promises' import path from 'path' import { pipeline } from 'stream/promises' -import { randomUUID } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator' import type { AccessList } from '../../@types/AccessList.js' @@ -30,7 +30,9 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { const options = node.getConfig().persistentStorage .options as PersistentStorageLocalFSOptions - this.baseFolder = options.folder + // Resolve to an absolute path so all derived paths (incl. Docker bind-mount Source, + // which must be absolute) are absolute even when a relative folder is configured. + this.baseFolder = path.resolve(options.folder) // Ensure base folder exists and is a directory (sync to avoid startup races). try { @@ -242,5 +244,51 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { ReadOnly: false } } + + async getFileChecksum( + bucketId: string, + fileName: string, + consumerAddress: string + ): Promise { + await this.ensureBucketExists(bucketId) + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + await this.ensureFileExists(bucketId, fileName) + + const targetPath = path.join(this.bucketPath(bucketId), fileName) + const hash = createHash('sha256') + await pipeline(fs.createReadStream(targetPath), hash) + return hash.digest('hex') + } + + async getFileInfo( + bucketId: string, + fileName: string, + consumerAddress?: string + ): Promise<{ size: number; lastModified: number }> { + await this.ensureBucketExists(bucketId) + if (consumerAddress) { + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + } + await this.ensureFileExists(bucketId, fileName) + + const targetPath = path.join(this.bucketPath(bucketId), fileName) + const st = await fsp.stat(targetPath) + return { size: st.size, lastModified: st.mtimeMs } + } + + async getReadableStream( + bucketId: string, + fileName: string, + consumerAddress?: string + ): Promise { + await this.ensureBucketExists(bucketId) + if (consumerAddress) { + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + } + await this.ensureFileExists(bucketId, fileName) + + const targetPath = path.join(this.bucketPath(bucketId), fileName) + return fs.createReadStream(targetPath) + } } /* eslint-enable security/detect-non-literal-fs-filename */ diff --git a/src/components/persistentStorage/PersistentStorageS3.ts b/src/components/persistentStorage/PersistentStorageS3.ts index a823cff2c..ceac072e3 100644 --- a/src/components/persistentStorage/PersistentStorageS3.ts +++ b/src/components/persistentStorage/PersistentStorageS3.ts @@ -92,4 +92,31 @@ export class PersistentStorageS3 extends PersistentStorageFactory { ): Promise { throw new Error('PersistentStorageS3 is not implemented yet') } + + // eslint-disable-next-line require-await + async getFileChecksum( + _bucketId: string, + _fileName: string, + _consumerAddress: string + ): Promise { + throw new Error('PersistentStorageS3 is not implemented yet') + } + + // eslint-disable-next-line require-await + async getFileInfo( + _bucketId: string, + _fileName: string, + _consumerAddress?: string + ): Promise<{ size: number; lastModified: number }> { + throw new Error('PersistentStorageS3 is not implemented yet') + } + + // eslint-disable-next-line require-await + async getReadableStream( + _bucketId: string, + _fileName: string, + _consumerAddress?: string + ): Promise { + throw new Error('PersistentStorageS3 is not implemented yet') + } } diff --git a/src/components/storage/NodePersistentStorage.ts b/src/components/storage/NodePersistentStorage.ts new file mode 100644 index 000000000..d59c3dae5 --- /dev/null +++ b/src/components/storage/NodePersistentStorage.ts @@ -0,0 +1,92 @@ +import { Readable } from 'stream' +import { + FileInfoResponse, + PersistentStorageObject, + StorageReadable +} from '../../@types/fileObject.js' +import { OceanNodeConfig } from '../../@types/OceanNode.js' +import { OceanNode } from '../../OceanNode.js' +import { PersistentStorageFactory } from '../persistentStorage/PersistentStorageFactory.js' +import { Storage } from './Storage.js' + +/** + * Storage class for node persistent-storage (localfs bucket) file objects. + * Unlike the other backends, persistent storage lives on the node itself, so this + * class reaches it through the OceanNode singleton. ACL is enforced by the backend + * whenever a consumerAddress is available (captured at construction time, since the + * Storage interface does not pass it to the read methods). + */ +export class NodePersistentStorage extends Storage { + private consumerAddress?: string + + public constructor( + file: PersistentStorageObject, + config: OceanNodeConfig, + consumerAddress?: string + ) { + super(file, config, false) + this.consumerAddress = consumerAddress + const [isValid, message] = this.validate() + if (isValid === false) { + throw new Error(`Error validating the persistent storage file: ${message}`) + } + } + + private backend(): PersistentStorageFactory { + if (!this.config.persistentStorage?.enabled) { + throw new Error('Persistent storage is not enabled on this node') + } + const ps = OceanNode.getInstance().getPersistentStorage() + if (!ps) { + throw new Error('Persistent storage is not available on this node') + } + return ps + } + + validate(): [boolean, string] { + const file = this.getFile() as PersistentStorageObject + if (!file?.bucketId) { + return [false, 'Missing bucketId'] + } + if (!file?.fileName) { + return [false, 'Missing fileName'] + } + if (!this.config.persistentStorage?.enabled) { + return [false, 'Persistent storage is not enabled on this node'] + } + // Stay backend-agnostic: a non-localfs backend will throw at read time. + return [true, ''] + } + + override async getReadableStream(): Promise { + const { bucketId, fileName } = this.getFile() as PersistentStorageObject + const stream = await this.backend().getReadableStream( + bucketId, + fileName, + this.consumerAddress + ) + return { stream: stream as Readable, httpStatus: 200, headers: {} } + } + + async fetchSpecificFileMetadata( + fileObject: PersistentStorageObject, + forceChecksum: boolean + ): Promise { + const { bucketId, fileName } = fileObject + const ps = this.backend() + const { size } = await ps.getFileInfo(bucketId, fileName, this.consumerAddress) + // getFileChecksum always enforces ACL and requires a consumerAddress; skip when absent. + let checksum: string | undefined + if (forceChecksum && this.consumerAddress) { + checksum = await ps.getFileChecksum(bucketId, fileName, this.consumerAddress) + } + return { + valid: true, + contentLength: String(size), + contentType: 'application/octet-stream', + checksum, + name: fileName, + type: 'nodePersistentStorage' + } + } +} diff --git a/src/components/storage/Storage.ts b/src/components/storage/Storage.ts index 9d74dd44b..ad04f2d56 100644 --- a/src/components/storage/Storage.ts +++ b/src/components/storage/Storage.ts @@ -11,8 +11,13 @@ import { OceanNodeConfig } from '../../@types/OceanNode.js' import { CORE_LOGGER } from '../../utils/logging/common.js' export abstract class Storage { - // eslint-disable-next-line no-use-before-define -- static factory return type references this class - static getStorageClass: (file: any, config: OceanNodeConfig) => Storage + /* eslint-disable no-use-before-define -- static factory return type references this class */ + static getStorageClass: ( + file: any, + config: OceanNodeConfig, + consumerAddress?: string + ) => Storage + /* eslint-enable no-use-before-define */ private file: StorageObject config: OceanNodeConfig diff --git a/src/components/storage/getStorageClass.ts b/src/components/storage/getStorageClass.ts index fbad7f507..c348b7711 100644 --- a/src/components/storage/getStorageClass.ts +++ b/src/components/storage/getStorageClass.ts @@ -7,6 +7,7 @@ import { FTPStorage } from './FTPStorage.js' import { IpfsStorage } from './IpfsStorage.js' import { S3Storage } from './S3Storage.js' import { UrlStorage } from './UrlStorage.js' +import { NodePersistentStorage } from './NodePersistentStorage.js' export type StorageClass = | UrlStorage @@ -14,8 +15,13 @@ export type StorageClass = | ArweaveStorage | S3Storage | FTPStorage + | NodePersistentStorage -export function getStorageClass(file: any, config: OceanNodeConfig): StorageClass { +export function getStorageClass( + file: any, + config: OceanNodeConfig, + consumerAddress?: string +): StorageClass { if (!file) { throw new Error('Empty file object') } @@ -34,6 +40,8 @@ export function getStorageClass(file: any, config: OceanNodeConfig): StorageClas return new S3Storage(file, config) case FileObjectType.FTP: return new FTPStorage(file, config) + case FileObjectType.NODE_PERSISTENT_STORAGE.toLowerCase(): + return new NodePersistentStorage(file, config, consumerAddress) default: throw new Error(`Invalid storage type: ${type}`) } diff --git a/src/components/storage/index.ts b/src/components/storage/index.ts index 62a1f310d..9b649b415 100644 --- a/src/components/storage/index.ts +++ b/src/components/storage/index.ts @@ -5,7 +5,16 @@ import { FTPStorage } from './FTPStorage.js' import { IpfsStorage } from './IpfsStorage.js' import { S3Storage } from './S3Storage.js' import { UrlStorage } from './UrlStorage.js' +import { NodePersistentStorage } from './NodePersistentStorage.js' Storage.getStorageClass = getStorageClass -export { Storage, UrlStorage, ArweaveStorage, IpfsStorage, S3Storage, FTPStorage } +export { + Storage, + UrlStorage, + ArweaveStorage, + IpfsStorage, + S3Storage, + FTPStorage, + NodePersistentStorage +} diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index e3577e6b6..3e5bce3a0 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -2272,6 +2272,138 @@ describe('********** Compute', () => { ) } + // create a bucket owned by `account` and upload `content` under `fileName` + const createBucketAndUpload = async ( + account: any, + fileName: string, + content: string | Buffer + ): Promise => { + const consumerAddress = await account.getAddress() + let nonce = Date.now().toString() + let signature = await safeSign( + account, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + ) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + assert.equal(createRes.status.httpStatus, 200) + const bucketId = (await streamToObject(createRes.stream as Readable)) + .bucketId as string + + nonce = Date.now().toString() + signature = await safeSign( + account, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId, + fileName, + stream: Readable.from(Buffer.isBuffer(content) ? content : Buffer.from(content)) + } as any) + assert.equal(uploadRes.status.httpStatus, 200) + return bucketId + } + + // ECIES-encrypted file object (hex) wrapping a nodePersistentStorage reference, + // mirroring how an encrypted DDO service.files would look + const encryptPSFileObject = async ( + bucketId: string, + fileName: string + ): Promise => { + const data = Uint8Array.from( + Buffer.from( + JSON.stringify({ + files: [{ type: 'nodePersistentStorage', bucketId, fileName }] + }) + ) + ) + const encrypted = await oceanNode.getKeyManager().encrypt(data, EncryptMethod.ECIES) + return Buffer.from(encrypted).toString('hex') + } + + const buildFreeStart = async ( + account: any, + datasets: any[], + algorithm: any + ): Promise => { + const consumerAddress = await account.getAddress() + const nonce = Date.now().toString() + const signature = await safeSign( + account, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.FREE_COMPUTE_START + ) + ) + return { + command: PROTOCOL_COMMANDS.FREE_COMPUTE_START, + consumerAddress, + signature, + nonce, + environment: firstEnv.id, + queueMaxWaitTime: 0, + datasets, + algorithm, + output: null + } + } + + // run a free compute job to completion and return the extracted contents of a + // single output file written by the algorithm into /data/outputs + const runFreeJobAndReadOutput = async ( + account: any, + datasets: any[], + algorithm: any, + outputFileName: string + ): Promise => { + const startTask = await buildFreeStart(account, datasets, algorithm) + const startRes = await new FreeComputeStartHandler(oceanNode).handle(startTask) + assert.equal(startRes.status.httpStatus, 200, String(startRes.status.error)) + const started = await streamToObject(startRes.stream as Readable) + const fullJobId = started[0].jobId as string + const innerJobId = fullJobId.slice(fullJobId.indexOf('-') + 1) + await waitForComputeJobFinished(oceanNode, fullJobId, 180_000) + + const base = (psDockerEngine as any).getStoragePath() as string + const outputsTarPath = path.join(base, innerJobId, 'data/outputs/outputs.tar') + /* eslint-disable security/detect-non-literal-fs-filename -- job paths from C2D engine */ + assert(existsSync(outputsTarPath), `expected outputs archive at ${outputsTarPath}`) + const extractDir = await fsp.mkdtemp(path.join(tmpdir(), 'ocean-ps-out-')) + try { + await tar.x({ file: outputsTarPath, cwd: extractDir }, [ + `outputs/${outputFileName}` + ]) + const extracted = path.join(extractDir, `outputs/${outputFileName}`) + assert( + existsSync(extracted), + `expected outputs/${outputFileName} inside outputs.tar` + ) + return await fsp.readFile(extracted, 'utf8') + } finally { + await fsp.rm(extractDir, { recursive: true, force: true }) + } + /* eslint-enable security/detect-non-literal-fs-filename */ + } + before(async function () { try { const d = new Dockerode() @@ -2603,6 +2735,207 @@ describe('********** Compute', () => { ) }) + it('reads a persistent storage dataset provided as an ENCRYPTED file object', async function () { + const fileName = 'enc-ps-data.txt' + const secret = 'ENCRYPTED_PS_DATASET_OK\n' + const bucketId = await createBucketAndUpload(consumerAccount, fileName, secret) + const encryptedFileObject = await encryptPSFileObject(bucketId, fileName) + + const rawcode = [ + "const fs = require('fs');", + `const p = '/data/persistentStorage/${bucketId}/${fileName}';`, + "fs.mkdirSync('/data/outputs', { recursive: true });", + "fs.writeFileSync('/data/outputs/enc-result.txt', fs.readFileSync(p, 'utf8'), 'utf8');" + ].join('\n') + const algoMeta = publishedAlgoDataset.ddo.metadata.algorithm + + const written = await runFreeJobAndReadOutput( + consumerAccount, + [{ fileObject: encryptedFileObject as any }], + { meta: { ...algoMeta, rawcode } }, + 'enc-result.txt' + ) + assert.equal(written, secret) + }) + + it('runs an ALGORITHM stored in persistent storage (no longer banned)', async function () { + const algoFileName = 'algo.js' + const inputFileName = 'algo-input.txt' + const algoCode = [ + "const fs = require('fs');", + "fs.mkdirSync('/data/outputs', { recursive: true });", + "fs.writeFileSync('/data/outputs/algo-result.txt', 'PS_ALGORITHM_OK\\n', 'utf8');" + ].join('\n') + const bucketId = await createBucketAndUpload( + consumerAccount, + algoFileName, + algoCode + ) + // upload an input file into the same bucket so the job has a dataset + await (async () => { + const consumerAddress = await consumerAccount.getAddress() + const nonce = Date.now().toString() + const signature = await safeSign( + consumerAccount, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId, + fileName: inputFileName, + stream: Readable.from(Buffer.from('input\n')) + } as any) + assert.equal(uploadRes.status.httpStatus, 200) + })() + + const algoMeta = publishedAlgoDataset.ddo.metadata.algorithm + const written = await runFreeJobAndReadOutput( + consumerAccount, + [ + { + fileObject: { + type: 'nodePersistentStorage', + bucketId, + fileName: inputFileName + } as any + } + ], + { + meta: { ...algoMeta }, + fileObject: { + type: 'nodePersistentStorage', + bucketId, + fileName: algoFileName + } as any + }, + 'algo-result.txt' + ) + assert.equal(written, 'PS_ALGORITHM_OK\n') + }) + + it('handles a MIX of persistent-storage and non-persistent-storage datasets', async function () { + const fileName = 'mixed-ps.txt' + const secret = 'MIXED_PS_OK\n' + const bucketId = await createBucketAndUpload(consumerAccount, fileName, secret) + const encryptedFileObject = await encryptPSFileObject(bucketId, fileName) + + const rawcode = [ + "const fs = require('fs');", + `const ps = fs.readFileSync('/data/persistentStorage/${bucketId}/${fileName}', 'utf8');`, + "const inputs = fs.readdirSync('/data/inputs').filter(f => f !== 'algoCustomData.json');", + "fs.mkdirSync('/data/outputs', { recursive: true });", + "fs.writeFileSync('/data/outputs/mixed-result.txt', ps + '|inputs=' + inputs.length, 'utf8');" + ].join('\n') + const algoMeta = publishedAlgoDataset.ddo.metadata.algorithm + + const written = await runFreeJobAndReadOutput( + consumerAccount, + [ + { fileObject: encryptedFileObject as any }, + { + fileObject: { + type: 'url', + method: 'GET', + url: 'https://raw.githubusercontent.com/oceanprotocol/test-algorithm/master/javascript/algo.js' + } as any + } + ], + { meta: { ...algoMeta, rawcode } }, + 'mixed-result.txt' + ) + // the persistent-storage dataset is bind-mounted, the URL dataset is downloaded + // into /data/inputs alongside algoCustomData.json + assert(written.startsWith(secret + '|inputs='), `unexpected output: ${written}`) + const count = parseInt(written.split('|inputs=')[1], 10) + assert(count >= 1, `expected at least one downloaded non-PS input, got ${count}`) + }) + + it('denies a persistent-storage ALGORITHM when the consumer is not on the bucket ACL', async function () { + const algoFileName = 'private-algo.js' + const bucketId = await createBucketAndUpload( + consumerAccount, + algoFileName, + "console.log('noop');" + ) + + const intruder = nonAllowedAccount + const algoMeta = publishedAlgoDataset.ddo.metadata.algorithm + const startTask = await buildFreeStart( + intruder, + [ + { + fileObject: { + type: 'nodePersistentStorage', + bucketId, + fileName: algoFileName + } as any + } + ], + { + meta: { ...algoMeta }, + fileObject: { + type: 'nodePersistentStorage', + bucketId, + fileName: algoFileName + } as any + } + ) + const startRes = await new FreeComputeStartHandler(oceanNode).handle(startTask) + assert.equal(startRes.status.httpStatus, 403, String(startRes.status.error)) + assert.include((startRes.status.error || '').toLowerCase(), 'allow') + }) + + it('getAlgoChecksums computes a real content checksum for a PUBLISHED persistent-storage algorithm', async function () { + this.timeout(DEFAULT_TEST_TIMEOUT * 6) + const algoFileName = 'published-algo.js' + const algoCode = "console.log('published ps algo');\n" + const bucketId = await createBucketAndUpload( + consumerAccount, + algoFileName, + algoCode + ) + const expected = createHash('sha256').update(Buffer.from(algoCode)).digest('hex') + + // publish an algorithm DDO whose (encrypted) service.files points to the bucket file + const psAlgoAsset = JSON.parse(JSON.stringify(algoAsset)) + psAlgoAsset.services[0].files.files = [ + { type: 'nodePersistentStorage', bucketId, fileName: algoFileName } + ] + const published = await publishAsset(psAlgoAsset, publisherAccount) + assert(published, 'failed to publish persistent-storage algorithm DDO') + + const { ddo, wasTimeout } = await waitToIndex( + oceanNode, + published.ddo.id, + EVENTS.METADATA_CREATED, + DEFAULT_TEST_TIMEOUT * 3, + true + ) + if (!ddo) { + expect(expectedTimeoutFailure(this.test.title)).to.be.equal(wasTimeout) + return + } + + const config = await getConfiguration() + const consumerAddress = await consumerAccount.getAddress() + const checksums = await getAlgoChecksums( + ddo.id, + ddo.services[0].id, + oceanNode, + config, + consumerAddress + ) + expect(checksums.files).to.equal(expected) + expect(checksums.container).to.not.equal('') + }) + describe('Compute output in bucket (outputBucketId)', function () { let outputBucketId: string const seedFileName = 'seed.txt' diff --git a/src/test/integration/persistentStorage.test.ts b/src/test/integration/persistentStorage.test.ts index 5f425efcc..5629e6084 100644 --- a/src/test/integration/persistentStorage.test.ts +++ b/src/test/integration/persistentStorage.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import fsp from 'fs/promises' import os from 'os' import path from 'path' -import { randomUUID } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { Readable } from 'stream' import { getAddress, JsonRpcProvider, Signer } from 'ethers' @@ -32,6 +32,9 @@ import { sleep } from '../utils/utils.js' import { createHashForSignature, safeSign } from '../utils/signature.js' +import { Storage, NodePersistentStorage } from '../../components/storage/index.js' +import { FileObjectType } from '../../@types/fileObject.js' +import { PersistentStorageLocalFS } from '../../components/persistentStorage/PersistentStorageLocalFS.js' import { BlockchainRegistry } from '../../components/BlockchainRegistry/index.js' import { Blockchain } from '../../utils/blockchain.js' @@ -136,6 +139,39 @@ describe('********** Persistent storage handlers (integration)', functio expect(nodeStatus.persistentStorage?.accessLists).to.be.an('array').with.lengthOf(1) }) + it('getDockerMountObject returns an absolute Source even when folder is relative', async () => { + // a relative folder must still produce an absolute bind-mount Source (Docker requires it) + const relativeFolder = '.tmp-ps-relative-mount-test' + const fakeNode = { + getConfig: () => ({ + persistentStorage: { + enabled: true, + type: 'localfs', + // accessLists: [], + options: { folder: relativeFolder } + } + }) + } as unknown as OceanNode + + const backend = new PersistentStorageLocalFS(fakeNode) + try { + const ownerAddress = await consumer.getAddress() + const { bucketId } = await backend.createNewBucket([], ownerAddress) + const fileName = 'rel.txt' + await backend.uploadFile( + bucketId, + fileName, + Readable.from(Buffer.from('x')), + ownerAddress + ) + + const mount = await backend.getDockerMountObject(bucketId, fileName) + expect(path.isAbsolute(mount.Source)).to.equal(true) + } finally { + await fsp.rm(path.resolve(relativeFolder), { recursive: true, force: true }) + } + }) + it('create bucket → upload → list → delete (happy path)', async () => { const consumerAddress = await consumer.getAddress() let nonce = Date.now().toString() @@ -313,6 +349,162 @@ describe('********** Persistent storage handlers (integration)', functio expect(obj.fileName).to.equal(fileName) }) + it('getFileChecksum returns the sha256 of the file contents for an allowed consumer', async () => { + const consumerAddress = await consumer.getAddress() + + let nonce = Date.now().toString() + let signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + ) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const bucketId = (await streamToObject(createRes.stream as Readable)) + .bucketId as string + + const fileName = 'checksum.bin' + const body = Buffer.from('persistent-storage-checksum-contents') + const expected = createHash('sha256').update(body).digest('hex') + + nonce = Date.now().toString() + signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId, + fileName, + stream: Readable.from(body) + } as any) + expect(uploadRes.status.httpStatus).to.equal(200) + + const ps = oceanNode.getPersistentStorage() + const checksum = await ps.getFileChecksum(bucketId, fileName, consumerAddress) + expect(checksum).to.equal(expected) + + // a consumer not on the bucket ACL must be denied + const intruderAddress = await forbiddenConsumer.getAddress() + let denied = false + try { + await ps.getFileChecksum(bucketId, fileName, intruderAddress) + } catch { + denied = true + } + expect(denied).to.equal(true) + }) + + it('getStorageClass returns a working NodePersistentStorage for nodePersistentStorage files', async () => { + const consumerAddress = await consumer.getAddress() + + let nonce = Date.now().toString() + let signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + ) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const bucketId = (await streamToObject(createRes.stream as Readable)) + .bucketId as string + + const fileName = 'storage-class.txt' + const body = Buffer.from('node-persistent-storage-class-contents') + const expectedChecksum = createHash('sha256').update(body).digest('hex') + + nonce = Date.now().toString() + signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId, + fileName, + stream: Readable.from(body) + } as any) + expect(uploadRes.status.httpStatus).to.equal(200) + + const fileObject = { + type: FileObjectType.NODE_PERSISTENT_STORAGE, + bucketId, + fileName + } + + // factory returns the right class + const storage = Storage.getStorageClass(fileObject, config, consumerAddress) + expect(storage).to.be.instanceOf(NodePersistentStorage) + + // metadata (with consumer -> checksum present) + const info = await storage.fetchSpecificFileMetadata(fileObject as any, true) + expect(info.valid).to.equal(true) + expect(info.contentLength).to.equal(String(body.length)) + expect(info.type).to.equal('nodePersistentStorage') + expect(info.checksum).to.equal(expectedChecksum) + + // readable stream returns the bytes + const { stream } = await storage.getReadableStream() + const chunks: Buffer[] = [] + for await (const chunk of stream as Readable) { + chunks.push(Buffer.from(chunk)) + } + expect(Buffer.concat(chunks).toString()).to.equal(body.toString()) + + // without a consumer, forceChecksum cannot run the ACL'd checksum -> undefined + const noConsumer = Storage.getStorageClass(fileObject, config) + const infoNoConsumer = await noConsumer.fetchSpecificFileMetadata( + fileObject as any, + true + ) + expect(infoNoConsumer.checksum).to.equal(undefined) + + // a consumer not on the bucket ACL is denied when reading + const intruderAddress = await forbiddenConsumer.getAddress() + const intruderStorage = Storage.getStorageClass(fileObject, config, intruderAddress) + let denied = false + try { + await intruderStorage.getReadableStream() + } catch { + denied = true + } + expect(denied).to.equal(true) + }) + it('should not create bucket when consumer is not on allow list', async () => { const forbiddenConsumerAddress = await forbiddenConsumer.getAddress() const nonce = Date.now().toString() From 125e0af5d19b91afd80f74473c8b847aae689a11 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 09:14:41 +0300 Subject: [PATCH 02/10] remove debug --- src/components/core/handler/ddoHandler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/core/handler/ddoHandler.ts b/src/components/core/handler/ddoHandler.ts index eefb1f4da..69b1adbc0 100644 --- a/src/components/core/handler/ddoHandler.ts +++ b/src/components/core/handler/ddoHandler.ts @@ -776,8 +776,6 @@ export class ValidateDDOHandler extends CommandHandler { try { const ddoInstance = DDOManager.getDDOClass(task.ddo) const validation = await ddoInstance.validate() - console.log(validation) - console.log(JSON.stringify(validation)) if (validation[0] === false) { CORE_LOGGER.logMessageWithEmoji( `Validation failed with error: ${validation[1]}`, From 03984e6df7dfdd17f5a14bc9f102cd3e3a5f4215 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 11:54:37 +0300 Subject: [PATCH 03/10] disable persistentStorage for download datasets --- .../core/handler/downloadHandler.ts | 28 ++++++++----------- src/test/unit/download.test.ts | 19 ++++++++++++- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/core/handler/downloadHandler.ts b/src/components/core/handler/downloadHandler.ts index 0597707cd..9a5cdc8b0 100644 --- a/src/components/core/handler/downloadHandler.ts +++ b/src/components/core/handler/downloadHandler.ts @@ -56,30 +56,28 @@ export function isOrderingAllowedForAsset(asset: Asset): OrdableAssetResponse { export async function handleDownloadUrlCommand( node: OceanNode, - task: DownloadURLCommand, - consumerAddress?: string + task: DownloadURLCommand ): Promise { const encryptFile = !!task.aes_encrypted_key CORE_LOGGER.logMessage('DownloadCommand requires file encryption? ' + encryptFile, true) const config = node.getConfig() try { - // Persistent-storage files are ACL-gated by the bucket; refuse to serve them - // unless we know the consumer (this command path has no order/credential gating). + // Persistent-storage files are only available within compute jobs, never via download. if ( (task.fileObject as { type?: string })?.type === - FileObjectType.NODE_PERSISTENT_STORAGE && - !consumerAddress + FileObjectType.NODE_PERSISTENT_STORAGE ) { return { stream: null, status: { httpStatus: 403, - error: 'Persistent storage files require an authenticated consumer' + error: + 'Persistent storage files cannot be downloaded; they are only available within compute jobs' } } } // Determine the type of storage and get a readable stream - const storage = Storage.getStorageClass(task.fileObject, config, consumerAddress) + const storage = Storage.getStorageClass(task.fileObject, config) // Validate storage configuration (checks if gateways are configured) const [isValid, validationError] = storage.validate() @@ -558,15 +556,11 @@ export class DownloadHandler extends CommandHandler { } // 8. Proceed to download the file - return await handleDownloadUrlCommand( - node, - { - fileObject: decriptedFileObject, - aes_encrypted_key: task.aes_encrypted_key, - command: PROTOCOL_COMMANDS.DOWNLOAD - }, - task.consumerAddress - ) + return await handleDownloadUrlCommand(node, { + fileObject: decriptedFileObject, + aes_encrypted_key: task.aes_encrypted_key, + command: PROTOCOL_COMMANDS.DOWNLOAD + }) } catch (e) { CORE_LOGGER.logMessage('Decryption error: ' + e, true) return { diff --git a/src/test/unit/download.test.ts b/src/test/unit/download.test.ts index f583c37a4..7198a97e2 100644 --- a/src/test/unit/download.test.ts +++ b/src/test/unit/download.test.ts @@ -15,7 +15,10 @@ import { setupEnvironment, tearDownEnvironment } from '../utils/utils.js' -import { validateFilesStructure } from '../../components/core/handler/downloadHandler.js' +import { + handleDownloadUrlCommand, + validateFilesStructure +} from '../../components/core/handler/downloadHandler.js' import { AssetUtils, isConfidentialChainDDO } from '../../utils/asset.js' import { DEVELOPMENT_CHAIN_ID, KNOWN_CONFIDENTIAL_EVMS } from '../../utils/address.js' import { DDO } from '@oceanprotocol/ddo-js' @@ -212,6 +215,20 @@ describe('Should validate files structure for download', () => { assert(decryptedFileData.nftAddress?.toLowerCase() === otherNFTAddress?.toLowerCase()) }) + it('should deny downloading a persistentStorage file object (compute-only)', async () => { + const result = await handleDownloadUrlCommand(oceanNode, { + fileObject: { + type: 'nodePersistentStorage', + bucketId: 'some-bucket', + fileName: 'data.txt' + } as any, + command: PROTOCOL_COMMANDS.DOWNLOAD + } as any) + expect(result.stream).to.equal(null) + expect(result.status.httpStatus).to.equal(403) + expect((result.status.error || '').toLowerCase()).to.include('compute') + }) + it('should check if DDO service files is missing or empty (exected for confidential EVM, dt4)', () => { const otherDDOConfidential = structuredClone(ddoObj) expect( From d01aafe525f9291e5044335cc3ff053b2179fef3 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 12:08:58 +0300 Subject: [PATCH 04/10] do not allow PS for download assets & bug fixes --- src/components/c2d/compute_engine_docker.ts | 100 +++++++++--------- src/components/core/compute/initialize.ts | 11 +- src/components/core/compute/startCompute.ts | 13 ++- .../core/handler/fileInfoHandler.ts | 20 +++- .../integration/persistentStorage.test.ts | 8 +- 5 files changed, 94 insertions(+), 58 deletions(-) diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 4f3ca127d..b36a3ef02 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -1963,14 +1963,14 @@ export class C2DEngineDocker extends C2DEngine { for (const i in job.assets) { const asset = job.assets[i] // resolve the effective file object (plaintext, encrypted, or via documentId/serviceId) - const resolved = await this.resolveComputeFileObject(asset) + const resolved = await resolveComputeFileObject(asset) if (!resolved || resolved.type !== 'nodePersistentStorage') { // non persistent-storage assets are downloaded later, during uploadData continue } - // persist the resolved (plaintext) persistent-storage file object back onto the job - // so the uploadData phase skips it without having to decrypt/resolve again - asset.fileObject = resolved + // keep `resolved` local — do NOT persist the decrypted bucketId/fileName back + // onto the job record. uploadData independently re-detects encrypted and + // DDO-derived persistent-storage assets and skips them. const fo = resolved as unknown as { bucketId?: string; fileName?: string } if (!fo.bucketId || !fo.fileName) { CORE_LOGGER.error( @@ -2850,50 +2850,6 @@ export class C2DEngineDocker extends C2DEngine { return filesObject } - /** - * Resolves the effective (decrypted) file object for a compute asset or algorithm. - * Handles the three ways a file object can arrive: - * 1. plaintext file object (already has a `type`) - * 2. encrypted file object (no `type`) -> decrypt it - * 3. no file object, only `documentId` + `serviceId` -> resolve the DDO and decrypt `service.files` - * Returns null if nothing could be resolved. - */ - private async resolveComputeFileObject( - item: ComputeAsset | ComputeAlgorithm - ): Promise { - try { - if (item.fileObject) { - // plaintext: type is directly available - if ((item.fileObject as BaseFileObject).type) { - return item.fileObject as BaseFileObject - } - // encrypted: decrypt to reveal the type - return await decryptFilesObject(item.fileObject) - } - // no file object: try documentId + serviceId - const { documentId, serviceId } = item - if (documentId && serviceId) { - const ddo = await new FindDdoHandler(OceanNode.getInstance()).findAndFormatDdo( - documentId - ) - if (!ddo) { - return null - } - const service: Service = AssetUtils.getServiceById(ddo, serviceId) - if (!service) { - return null - } - return await decryptFilesObject(service.files) - } - return null - } catch (e) { - CORE_LOGGER.error( - `Unable to resolve compute file object: ${e instanceof Error ? e.message : String(e)}` - ) - return null - } - } - private async uploadData( job: DBComputeJob ): Promise<{ status: C2DStatusNumber; statusText: C2DStatusText }> { @@ -2926,7 +2882,7 @@ export class C2DEngineDocker extends C2DEngine { // documentId/serviceId). All types — including nodePersistentStorage — are // streamed uniformly through the Storage class (job.owner enforces the bucket // ACL for persistent storage). - const resolved = await this.resolveComputeFileObject(job.algorithm) + const resolved = await resolveComputeFileObject(job.algorithm) if (!resolved) { CORE_LOGGER.info( 'Could not extract any files object from the compute algorithm, skipping...' @@ -3430,6 +3386,52 @@ export class C2DEngineDocker extends C2DEngine { // this uses the docker engine, but exposes only one env, the free one +/** + * Resolves the effective (decrypted) file object for a compute asset or algorithm. + * Handles the three ways a file object can arrive: + * 1. plaintext file object (already has a `type`) + * 2. encrypted file object (no `type`) -> decrypt it + * 3. no file object, only `documentId` + `serviceId` -> resolve the DDO and decrypt `service.files` + * Returns null if nothing could be resolved. Shared by the Docker provisioning path + * and the compute pre-checks (initialize/start) so persistent-storage ACL validation + * sees the same resolved object. + */ +export async function resolveComputeFileObject( + item: ComputeAsset | ComputeAlgorithm +): Promise { + try { + if (item.fileObject) { + // plaintext: type is directly available + if ((item.fileObject as BaseFileObject).type) { + return item.fileObject as BaseFileObject + } + // encrypted: decrypt to reveal the type + return await decryptFilesObject(item.fileObject) + } + // no file object: try documentId + serviceId + const { documentId, serviceId } = item + if (documentId && serviceId) { + const ddo = await new FindDdoHandler(OceanNode.getInstance()).findAndFormatDdo( + documentId + ) + if (!ddo) { + return null + } + const service: Service = AssetUtils.getServiceById(ddo, serviceId) + if (!service) { + return null + } + return await decryptFilesObject(service.files) + } + return null + } catch (e) { + CORE_LOGGER.error( + `Unable to resolve compute file object: ${e instanceof Error ? e.message : String(e)}` + ) + return null + } +} + export function getAlgorithmImage(algorithm: ComputeAlgorithm, jobId: string): string { if (!algorithm.meta || !algorithm.meta.container) { return null diff --git a/src/components/core/compute/initialize.ts b/src/components/core/compute/initialize.ts index b35e2f27a..d2bfd6b22 100644 --- a/src/components/core/compute/initialize.ts +++ b/src/components/core/compute/initialize.ts @@ -28,7 +28,10 @@ import { sanitizeServiceFiles } from '../../../utils/util.js' import { FindDdoHandler } from '../handler/ddoHandler.js' import { isOrderingAllowedForAsset } from '../handler/downloadHandler.js' import { getNonceAsNumber } from '../utils/nonceHandler.js' -import { getAlgorithmImage } from '../../c2d/compute_engine_docker.js' +import { + getAlgorithmImage, + resolveComputeFileObject +} from '../../c2d/compute_engine_docker.js' import { Credentials, DDOManager } from '@oceanprotocol/ddo-js' import { checkCredentials } from '../../../utils/credentials.js' @@ -223,10 +226,14 @@ export class ComputeInitializeHandler extends CommandHandler { return isValidOutput } for (const elem of [task.algorithm, ...task.datasets]) { + // resolve encrypted / documentId+serviceId references so persistent-storage ACL + // is validated here too (not only plaintext file objects) + const resolvedFileObject = + (await resolveComputeFileObject(elem)) ?? elem.fileObject const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( node, task.consumerAddress, - elem.fileObject + resolvedFileObject ) if (psAccess) { return psAccess diff --git a/src/components/core/compute/startCompute.ts b/src/components/core/compute/startCompute.ts index aa04e6ca0..79f2b7616 100644 --- a/src/components/core/compute/startCompute.ts +++ b/src/components/core/compute/startCompute.ts @@ -46,6 +46,7 @@ import { PolicyServer } from '../../policyServer/index.js' import { checkCredentials } from '../../../utils/credentials.js' import { checkAddressOnAccessList } from '../../../utils/accessList.js' import { ensureConsumerAllowedForPersistentStorageLocalfsFileObject } from '../../persistentStorage/PersistentStorageFactory.js' +import { resolveComputeFileObject } from '../../c2d/compute_engine_docker.js' export class CommonComputeHandler extends CommandHandler { validate(command: PaidComputeStartCommand): ValidateParams { @@ -232,10 +233,14 @@ export class PaidComputeStartHandler extends CommonComputeHandler { } const policyServer = new PolicyServer() for (const elem of [task.algorithm, ...task.datasets]) { + // resolve encrypted / documentId+serviceId references so persistent-storage ACL + // is validated here too (not only plaintext file objects) + const resolvedFileObject = + (await resolveComputeFileObject(elem)) ?? elem.fileObject const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( node, task.consumerAddress, - elem.fileObject + resolvedFileObject ) if (psAccess) { return psAccess @@ -780,10 +785,14 @@ export class FreeComputeStartHandler extends CommonComputeHandler { } const policyServer = new PolicyServer() for (const elem of [task.algorithm, ...task.datasets]) { + // resolve encrypted / documentId+serviceId references so persistent-storage ACL + // is validated here too (not only plaintext file objects) + const resolvedFileObject = + (await resolveComputeFileObject(elem)) ?? elem.fileObject const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( thisNode, task.consumerAddress, - elem.fileObject + resolvedFileObject ) if (psAccess) { return psAccess diff --git a/src/components/core/handler/fileInfoHandler.ts b/src/components/core/handler/fileInfoHandler.ts index 561636b61..bfb1ba12d 100644 --- a/src/components/core/handler/fileInfoHandler.ts +++ b/src/components/core/handler/fileInfoHandler.ts @@ -14,6 +14,15 @@ import { } from '../../httpRoutes/validateCommands.js' import { getFile } from '../../../utils/file.js' +// Case-insensitive match for the persistent-storage type, mirroring how getStorageClass +// routes on `type?.toLowerCase()` so casing variants can't slip past the fileInfo gates. +function isPersistentStorageType(type: unknown): boolean { + return ( + typeof type === 'string' && + type.toLowerCase() === FileObjectType.NODE_PERSISTENT_STORAGE.toLowerCase() + ) +} + async function formatMetadata( file: StorageObject, config: OceanNodeConfig @@ -27,7 +36,7 @@ async function formatMetadata( }> { // Persistent-storage files are ACL-gated and not exposed through fileInfo; return a // generic entry instead of querying the backend (which would leak size/existence). - if ((file as { type?: string })?.type === FileObjectType.NODE_PERSISTENT_STORAGE) { + if (isPersistentStorageType((file as { type?: string })?.type)) { return { valid: true, contentLength: '', @@ -74,8 +83,13 @@ export class FileInfoHandler extends CommandHandler { 'Invalid Request: type must be one of ' + Object.values(FileObjectType).join(', ') ) } - // persistent storage files are ACL-gated and not served through fileInfo - if (command.type === FileObjectType.NODE_PERSISTENT_STORAGE) { + // persistent storage files are ACL-gated and not served through fileInfo. Check both + // the top-level command type AND the embedded file type (normalized for casing), since + // handle() routes getStorageClass on file.type — guarding only command.type is bypassable. + if ( + isPersistentStorageType(command.type) || + isPersistentStorageType(command.file?.type) + ) { return buildInvalidRequestMessage( 'Invalid Request: nodePersistentStorage files are not supported by fileInfo' ) diff --git a/src/test/integration/persistentStorage.test.ts b/src/test/integration/persistentStorage.test.ts index 5629e6084..06a1941c9 100644 --- a/src/test/integration/persistentStorage.test.ts +++ b/src/test/integration/persistentStorage.test.ts @@ -406,7 +406,9 @@ describe('********** Persistent storage handlers (integration)', functio let denied = false try { await ps.getFileChecksum(bucketId, fileName, intruderAddress) - } catch { + } catch (e) { + // only the expected access-denial counts; anything else fails the test + expect((e as Error).name).to.equal('PersistentStorageAccessDeniedError') denied = true } expect(denied).to.equal(true) @@ -499,7 +501,9 @@ describe('********** Persistent storage handlers (integration)', functio let denied = false try { await intruderStorage.getReadableStream() - } catch { + } catch (e) { + // only the expected access-denial counts; anything else fails the test + expect((e as Error).name).to.equal('PersistentStorageAccessDeniedError') denied = true } expect(denied).to.equal(true) From eaebbe8350857c27089cebe4dbdd727ce6d8e0e2 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 12:49:12 +0300 Subject: [PATCH 05/10] allow persistentStorage in fileInfo --- src/@types/commands.ts | 2 + src/@types/fileObject.ts | 10 +++ src/components/c2d/compute_engine_docker.ts | 14 +-- src/components/core/compute/utils.ts | 8 +- .../core/handler/fileInfoHandler.ts | 50 +++++------ src/components/httpRoutes/fileInfo.ts | 5 ++ .../PersistentStorageFactory.ts | 3 +- .../integration/persistentStorage.test.ts | 85 +++++++++++++++++++ 8 files changed, 145 insertions(+), 32 deletions(-) diff --git a/src/@types/commands.ts b/src/@types/commands.ts index 49d94b2ed..335a0b827 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -75,6 +75,8 @@ export interface FileInfoCommand extends Command { fileIndex?: number file?: StorageObject checksum?: boolean + // required only for nodePersistentStorage files, to gate on the bucket ACL + consumerAddress?: string } // group these 2 export interface DDOCommand extends Command { diff --git a/src/@types/fileObject.ts b/src/@types/fileObject.ts index 0da30fa62..b0a4b755d 100644 --- a/src/@types/fileObject.ts +++ b/src/@types/fileObject.ts @@ -79,6 +79,16 @@ export enum FileObjectType { NODE_PERSISTENT_STORAGE = 'nodePersistentStorage' } +// Case-insensitive match for the persistent-storage type. getStorageClass routes on +// `type?.toLowerCase()`, so all guard checks must normalize too or a casing variant +// (e.g. "NodePersistentStorage") slips past the guard and is still routed as PS. +export function isPersistentStorageType(type: unknown): boolean { + return ( + typeof type === 'string' && + type.toLowerCase() === FileObjectType.NODE_PERSISTENT_STORAGE.toLowerCase() + ) +} + export interface FileInfoRequest { type: FileObjectType fileIndex?: number diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index b36a3ef02..1cba837e6 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -59,7 +59,11 @@ import { ValidateParams } from '../httpRoutes/validateCommands.js' import { Service } from '@oceanprotocol/ddo-js' import { getOceanTokenAddressForChain } from '../../utils/address.js' import { dockerRegistryAuth, OceanNodeConfig } from '../../@types/OceanNode.js' -import { BaseFileObject, EncryptMethod } from '../../@types/fileObject.js' +import { + BaseFileObject, + EncryptMethod, + isPersistentStorageType +} from '../../@types/fileObject.js' import { getAddress, ZeroAddress } from 'ethers' import { AccessList } from '../../@types/AccessList.js' @@ -1964,7 +1968,7 @@ export class C2DEngineDocker extends C2DEngine { const asset = job.assets[i] // resolve the effective file object (plaintext, encrypted, or via documentId/serviceId) const resolved = await resolveComputeFileObject(asset) - if (!resolved || resolved.type !== 'nodePersistentStorage') { + if (!resolved || !isPersistentStorageType(resolved.type)) { // non persistent-storage assets are downloaded later, during uploadData continue } @@ -2935,7 +2939,7 @@ export class C2DEngineDocker extends C2DEngine { if (asset.fileObject) { try { if (asset.fileObject.type) { - if (asset.fileObject.type === 'nodePersistentStorage') { + if (isPersistentStorageType(asset.fileObject.type)) { // persistent storage is handled during ConfiguringVolumes via bind mounts continue } @@ -2946,7 +2950,7 @@ export class C2DEngineDocker extends C2DEngine { let filesObject: any = await decryptFilesObject(asset.fileObject) // persistent storage assets are bind-mounted during ConfiguringVolumes; // skip download here even if the resolved object wasn't written back - if (filesObject?.type === 'nodePersistentStorage') { + if (isPersistentStorageType(filesObject?.type)) { continue } filesObject = await this.addUserDataToFilesObject(filesObject, asset.userdata) @@ -2986,7 +2990,7 @@ export class C2DEngineDocker extends C2DEngine { // 3. Decrypt the url let decryptedFileObject = await decryptFilesObject(service.files) // persistent storage assets are bind-mounted during ConfiguringVolumes - if (decryptedFileObject?.type === 'nodePersistentStorage') { + if (isPersistentStorageType(decryptedFileObject?.type)) { continue } decryptedFileObject = await this.addUserDataToFilesObject( diff --git a/src/components/core/compute/utils.ts b/src/components/core/compute/utils.ts index 0f88c0125..26830e31c 100644 --- a/src/components/core/compute/utils.ts +++ b/src/components/core/compute/utils.ts @@ -1,7 +1,11 @@ import { OceanNode } from '../../../OceanNode.js' import { AlgoChecksums, ComputeOutput } from '../../../@types/C2D/C2D.js' import { OceanNodeConfig } from '../../../@types/OceanNode.js' -import { StorageObject, EncryptMethod } from '../../../@types/fileObject.js' +import { + StorageObject, + EncryptMethod, + isPersistentStorageType +} from '../../../@types/fileObject.js' import { getFile } from '../../../utils/file.js' import { Storage } from '../../storage/index.js' @@ -48,7 +52,7 @@ export async function getAlgoChecksums( for (const file of fileArray) { // persistent storage checksums require a consumerAddress (ACL); guard so a // missing consumer errors instead of silently producing an empty checksum - if ((file as any)?.type === 'nodePersistentStorage' && !consumerAddress) { + if (isPersistentStorageType((file as any)?.type) && !consumerAddress) { throw new Error( 'Unable to compute checksum for persistent storage algorithm file: missing consumerAddress' ) diff --git a/src/components/core/handler/fileInfoHandler.ts b/src/components/core/handler/fileInfoHandler.ts index bfb1ba12d..cdf9fa874 100644 --- a/src/components/core/handler/fileInfoHandler.ts +++ b/src/components/core/handler/fileInfoHandler.ts @@ -1,6 +1,10 @@ import { Readable } from 'stream' import { P2PCommandResponse } from '../../../@types/index.js' -import { FileObjectType, StorageObject } from '../../../@types/fileObject.js' +import { + FileObjectType, + StorageObject, + isPersistentStorageType +} from '../../../@types/fileObject.js' import { OceanNodeConfig } from '../../../@types/OceanNode.js' import { FileInfoCommand } from '../../../@types/commands.js' import { CORE_LOGGER } from '../../../utils/logging/common.js' @@ -14,18 +18,10 @@ import { } from '../../httpRoutes/validateCommands.js' import { getFile } from '../../../utils/file.js' -// Case-insensitive match for the persistent-storage type, mirroring how getStorageClass -// routes on `type?.toLowerCase()` so casing variants can't slip past the fileInfo gates. -function isPersistentStorageType(type: unknown): boolean { - return ( - typeof type === 'string' && - type.toLowerCase() === FileObjectType.NODE_PERSISTENT_STORAGE.toLowerCase() - ) -} - async function formatMetadata( file: StorageObject, - config: OceanNodeConfig + config: OceanNodeConfig, + consumerAddress?: string ): Promise<{ valid: boolean contentLength: string @@ -34,9 +30,10 @@ async function formatMetadata( name: string type: string }> { - // Persistent-storage files are ACL-gated and not exposed through fileInfo; return a - // generic entry instead of querying the backend (which would leak size/existence). - if (isPersistentStorageType((file as { type?: string })?.type)) { + // Persistent-storage files are ACL-gated: only resolve real metadata when a + // consumerAddress is supplied (the backend then enforces the bucket ACL). Without it, + // return a generic entry instead of querying the backend, to avoid leaking size/existence. + if (isPersistentStorageType((file as { type?: string })?.type) && !consumerAddress) { return { valid: true, contentLength: '', @@ -45,7 +42,7 @@ async function formatMetadata( type: FileObjectType.NODE_PERSISTENT_STORAGE } } - const storage = Storage.getStorageClass(file, config) + const storage = Storage.getStorageClass(file, config, consumerAddress) const fileInfo = await storage.fetchSpecificFileMetadata(file, false) CORE_LOGGER.logMessage( `Metadata for file: ${fileInfo.contentLength} ${fileInfo.contentType}` @@ -83,15 +80,16 @@ export class FileInfoHandler extends CommandHandler { 'Invalid Request: type must be one of ' + Object.values(FileObjectType).join(', ') ) } - // persistent storage files are ACL-gated and not served through fileInfo. Check both - // the top-level command type AND the embedded file type (normalized for casing), since - // handle() routes getStorageClass on file.type — guarding only command.type is bypassable. + // persistent storage files are ACL-gated: a consumerAddress is required so the bucket + // ACL can be enforced. Check both the top-level command type AND the embedded file type + // (normalized for casing), since handle() routes getStorageClass on file.type. if ( - isPersistentStorageType(command.type) || - isPersistentStorageType(command.file?.type) + (isPersistentStorageType(command.type) || + isPersistentStorageType(command.file?.type)) && + !command.consumerAddress ) { return buildInvalidRequestMessage( - 'Invalid Request: nodePersistentStorage files are not supported by fileInfo' + 'Invalid Request: consumerAddress is required for nodePersistentStorage files' ) } @@ -109,7 +107,7 @@ export class FileInfoHandler extends CommandHandler { let fileInfo = [] if (task.file && task.type) { - const storage = Storage.getStorageClass(task.file, config) + const storage = Storage.getStorageClass(task.file, config, task.consumerAddress) fileInfo = await storage.getFileInfo({ type: task.type, @@ -118,11 +116,15 @@ export class FileInfoHandler extends CommandHandler { } else if (task.did && task.serviceId) { const fileArray = await getFile(task.did, task.serviceId, oceanNode) if (task.fileIndex) { - const fileMetadata = await formatMetadata(fileArray[task.fileIndex], config) + const fileMetadata = await formatMetadata( + fileArray[task.fileIndex], + config, + task.consumerAddress + ) fileInfo.push(fileMetadata) } else { for (const file of fileArray) { - const fileMetadata = await formatMetadata(file, config) + const fileMetadata = await formatMetadata(file, config, task.consumerAddress) fileInfo.push(fileMetadata) } } diff --git a/src/components/httpRoutes/fileInfo.ts b/src/components/httpRoutes/fileInfo.ts index 85925b352..6f9ae7f5a 100644 --- a/src/components/httpRoutes/fileInfo.ts +++ b/src/components/httpRoutes/fileInfo.ts @@ -33,6 +33,9 @@ fileInfoRoute.post( res.status(400).send('Invalid request parameters') return } + // optional; required only for nodePersistentStorage files (gates on the bucket ACL) + const consumerAddress = (req.body as { consumerAddress?: string })?.consumerAddress + // Retrieve the file info let fileObject: StorageObject let fileInfoTask: FileInfoCommand @@ -42,6 +45,7 @@ fileInfoRoute.post( command: PROTOCOL_COMMANDS.FILE_INFO, did: fileInfoReq.did, serviceId: fileInfoReq.serviceId, + consumerAddress, caller: req.caller } } else { @@ -51,6 +55,7 @@ fileInfoRoute.post( command: PROTOCOL_COMMANDS.FILE_INFO, file: fileObject, type: fileObject.type as FileObjectType, + consumerAddress, caller: req.caller } } diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index 2adf475f7..306d771c7 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -1,4 +1,5 @@ import { P2PCommandResponse } from '../../@types/index.js' +import { isPersistentStorageType } from '../../@types/fileObject.js' import type { AccessList } from '../../@types/AccessList.js' import type { DockerMountObject, @@ -405,7 +406,7 @@ export async function ensureConsumerAllowedForPersistentStorageLocalfsFileObject return null } const fo = fileObject as { type?: string; bucketId?: unknown } - if (fo.type !== 'nodePersistentStorage') { + if (!isPersistentStorageType(fo.type)) { return null } if (typeof fo.bucketId !== 'string' || fo.bucketId.length === 0) { diff --git a/src/test/integration/persistentStorage.test.ts b/src/test/integration/persistentStorage.test.ts index 06a1941c9..4bfb89a8c 100644 --- a/src/test/integration/persistentStorage.test.ts +++ b/src/test/integration/persistentStorage.test.ts @@ -35,6 +35,7 @@ import { createHashForSignature, safeSign } from '../utils/signature.js' import { Storage, NodePersistentStorage } from '../../components/storage/index.js' import { FileObjectType } from '../../@types/fileObject.js' import { PersistentStorageLocalFS } from '../../components/persistentStorage/PersistentStorageLocalFS.js' +import { FileInfoHandler } from '../../components/core/handler/fileInfoHandler.js' import { BlockchainRegistry } from '../../components/BlockchainRegistry/index.js' import { Blockchain } from '../../utils/blockchain.js' @@ -509,6 +510,90 @@ describe('********** Persistent storage handlers (integration)', functio expect(denied).to.equal(true) }) + it('fileInfo serves a persistentStorage file only with an allowed consumerAddress', async () => { + const consumerAddress = await consumer.getAddress() + + let nonce = Date.now().toString() + let signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + ) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const bucketId = (await streamToObject(createRes.stream as Readable)) + .bucketId as string + + const fileName = 'fileinfo.txt' + const body = Buffer.from('node-persistent-storage-fileinfo') + + nonce = Date.now().toString() + signature = await safeSign( + consumer, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId, + fileName, + stream: Readable.from(body) + } as any) + expect(uploadRes.status.httpStatus).to.equal(200) + + const fileObject = { + type: FileObjectType.NODE_PERSISTENT_STORAGE, + bucketId, + fileName + } + + // allowed consumer -> metadata returned + const okRes = await new FileInfoHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.FILE_INFO, + file: fileObject as any, + type: FileObjectType.NODE_PERSISTENT_STORAGE, + consumerAddress + } as any) + expect(okRes.status.httpStatus).to.equal(200) + const info = await streamToObject(okRes.stream as Readable) + expect(info[0].contentLength).to.equal(String(body.length)) + expect(info[0].type).to.equal('nodePersistentStorage') + + // missing consumerAddress -> rejected at validation + const noConsumerRes = await new FileInfoHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.FILE_INFO, + file: fileObject as any, + type: FileObjectType.NODE_PERSISTENT_STORAGE + } as any) + expect(noConsumerRes.status.httpStatus).to.not.equal(200) + + // consumer not on the bucket ACL -> backend denies -> error, no metadata + const intruderAddress = await forbiddenConsumer.getAddress() + const deniedRes = await new FileInfoHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.FILE_INFO, + file: fileObject as any, + type: FileObjectType.NODE_PERSISTENT_STORAGE, + consumerAddress: intruderAddress + } as any) + expect(deniedRes.status.httpStatus).to.not.equal(200) + }) + it('should not create bucket when consumer is not on allow list', async () => { const forbiddenConsumerAddress = await forbiddenConsumer.getAddress() const nonce = Date.now().toString() From 67d6beb480f3b38baacca6e49191f131a557c91d Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 13:07:26 +0300 Subject: [PATCH 06/10] fix fileInfo when no consumerAddress is present for persistentStorage --- src/components/core/handler/fileInfoHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/core/handler/fileInfoHandler.ts b/src/components/core/handler/fileInfoHandler.ts index cdf9fa874..70b64604a 100644 --- a/src/components/core/handler/fileInfoHandler.ts +++ b/src/components/core/handler/fileInfoHandler.ts @@ -32,10 +32,10 @@ async function formatMetadata( }> { // Persistent-storage files are ACL-gated: only resolve real metadata when a // consumerAddress is supplied (the backend then enforces the bucket ACL). Without it, - // return a generic entry instead of querying the backend, to avoid leaking size/existence. + // return a generic entry if (isPersistentStorageType((file as { type?: string })?.type) && !consumerAddress) { return { - valid: true, + valid: false, contentLength: '', contentType: 'application/octet-stream', name: '', From afa322ee3d3bfd369494d9b1d14ada3475e10290 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Fri, 19 Jun 2026 13:51:18 +0300 Subject: [PATCH 07/10] add timeout --- src/test/integration/compute.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index 3e5bce3a0..96f408a58 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -2381,6 +2381,7 @@ describe('********** Compute', () => { const started = await streamToObject(startRes.stream as Readable) const fullJobId = started[0].jobId as string const innerJobId = fullJobId.slice(fullJobId.indexOf('-') + 1) + await sleep(2000) // give the job a moment to start and create its output directory await waitForComputeJobFinished(oceanNode, fullJobId, 180_000) const base = (psDockerEngine as any).getStoragePath() as string From 1b2b21e586286703aa2638a8a2a9fef698c7cf52 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Sat, 20 Jun 2026 09:31:39 +0300 Subject: [PATCH 08/10] fix comments --- src/components/core/compute/startCompute.ts | 33 ++++++++++--------- .../core/handler/downloadHandler.ts | 7 ++-- .../PersistentStorageLocalFS.ts | 22 +++++++++---- src/test/unit/download.test.ts | 14 ++++++++ 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/components/core/compute/startCompute.ts b/src/components/core/compute/startCompute.ts index 79f2b7616..5d4029a5d 100644 --- a/src/components/core/compute/startCompute.ts +++ b/src/components/core/compute/startCompute.ts @@ -208,6 +208,24 @@ export class PaidComputeStartHandler extends CommonComputeHandler { } } + const policyServer = new PolicyServer() + for (const elem of [task.algorithm, ...task.datasets]) { + // resolve encrypted / documentId+serviceId references so persistent-storage ACL + // is validated here too (not only plaintext file objects) + const resolvedFileObject = + (await resolveComputeFileObject(elem)) ?? elem.fileObject + const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( + node, + task.consumerAddress, + resolvedFileObject + ) + if (psAccess) { + return psAccess + } + } + + // ACL preflight is confirmed above before attempting checksum retrieval, + // so unauthorized consumers get an explicit ACL denial instead of a generic 500. const algoChecksums = await getAlgoChecksums( task.algorithm.documentId, task.algorithm.serviceId, @@ -231,21 +249,6 @@ export class PaidComputeStartHandler extends CommonComputeHandler { } } } - const policyServer = new PolicyServer() - for (const elem of [task.algorithm, ...task.datasets]) { - // resolve encrypted / documentId+serviceId references so persistent-storage ACL - // is validated here too (not only plaintext file objects) - const resolvedFileObject = - (await resolveComputeFileObject(elem)) ?? elem.fileObject - const psAccess = await ensureConsumerAllowedForPersistentStorageLocalfsFileObject( - node, - task.consumerAddress, - resolvedFileObject - ) - if (psAccess) { - return psAccess - } - } // check algo and datasets (orders, credentials, etc.) for (const elem of [...[task.algorithm], ...task.datasets]) { const result: any = { validOrder: false } diff --git a/src/components/core/handler/downloadHandler.ts b/src/components/core/handler/downloadHandler.ts index 9a5cdc8b0..c56ad613a 100644 --- a/src/components/core/handler/downloadHandler.ts +++ b/src/components/core/handler/downloadHandler.ts @@ -19,7 +19,7 @@ import { checkCredentials } from '../../../utils/credentials.js' import { CORE_LOGGER } from '../../../utils/logging/common.js' import { OceanNode } from '../../../OceanNode.js' import { DownloadCommand, DownloadURLCommand } from '../../../@types/commands.js' -import { EncryptMethod, FileObjectType } from '../../../@types/fileObject.js' +import { EncryptMethod, isPersistentStorageType } from '../../../@types/fileObject.js' import { validateCommandParameters, @@ -63,10 +63,7 @@ export async function handleDownloadUrlCommand( const config = node.getConfig() try { // Persistent-storage files are only available within compute jobs, never via download. - if ( - (task.fileObject as { type?: string })?.type === - FileObjectType.NODE_PERSISTENT_STORAGE - ) { + if (isPersistentStorageType((task.fileObject as { type?: string })?.type)) { return { stream: null, status: { diff --git a/src/components/persistentStorage/PersistentStorageLocalFS.ts b/src/components/persistentStorage/PersistentStorageLocalFS.ts index 2055a8e16..e74923e38 100644 --- a/src/components/persistentStorage/PersistentStorageLocalFS.ts +++ b/src/components/persistentStorage/PersistentStorageLocalFS.ts @@ -211,9 +211,12 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { consumerAddress?: string ): Promise { await this.ensureBucketExists(bucketId) - if (consumerAddress) { - await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + if (!consumerAddress) { + throw new Error( + 'Access denied: consumerAddress is required to access persistent storage' + ) } + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) await this.ensureFileExists(bucketId, fileName) const source = path.join(this.bucketPath(bucketId), fileName) @@ -251,7 +254,10 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { consumerAddress: string ): Promise { await this.ensureBucketExists(bucketId) - await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + // file checksum can be obtained without consumerAddress, but if provided, it will be validated for access. + if (consumerAddress) { + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + } await this.ensureFileExists(bucketId, fileName) const targetPath = path.join(this.bucketPath(bucketId), fileName) @@ -266,6 +272,7 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { consumerAddress?: string ): Promise<{ size: number; lastModified: number }> { await this.ensureBucketExists(bucketId) + // fileInfo can be obtained without consumerAddress, but if provided, it will be validated for access. if (consumerAddress) { await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) } @@ -273,7 +280,7 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { const targetPath = path.join(this.bucketPath(bucketId), fileName) const st = await fsp.stat(targetPath) - return { size: st.size, lastModified: st.mtimeMs } + return { size: st.size, lastModified: Math.floor(st.mtimeMs) } } async getReadableStream( @@ -282,9 +289,12 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { consumerAddress?: string ): Promise { await this.ensureBucketExists(bucketId) - if (consumerAddress) { - await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + if (!consumerAddress) { + throw new Error( + 'Access denied: consumerAddress is required to access persistent storage' + ) } + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) await this.ensureFileExists(bucketId, fileName) const targetPath = path.join(this.bucketPath(bucketId), fileName) diff --git a/src/test/unit/download.test.ts b/src/test/unit/download.test.ts index 7198a97e2..9e088233a 100644 --- a/src/test/unit/download.test.ts +++ b/src/test/unit/download.test.ts @@ -229,6 +229,20 @@ describe('Should validate files structure for download', () => { expect((result.status.error || '').toLowerCase()).to.include('compute') }) + it('should deny downloading a mixed-case persistentStorage file object (compute-only)', async () => { + const result = await handleDownloadUrlCommand(oceanNode, { + fileObject: { + type: 'NodePersistentStorage', + bucketId: 'some-bucket', + fileName: 'data.txt' + } as any, + command: PROTOCOL_COMMANDS.DOWNLOAD + } as any) + expect(result.stream).to.equal(null) + expect(result.status.httpStatus).to.equal(403) + expect((result.status.error || '').toLowerCase()).to.include('compute') + }) + it('should check if DDO service files is missing or empty (exected for confidential EVM, dt4)', () => { const otherDDOConfidential = structuredClone(ddoObj) expect( From 9c0aadaa3f155275990778179146e0cb24b8b9f7 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Sat, 20 Jun 2026 10:47:00 +0300 Subject: [PATCH 09/10] make consumerAddress optional in getFileChecksum --- src/components/persistentStorage/PersistentStorageLocalFS.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/persistentStorage/PersistentStorageLocalFS.ts b/src/components/persistentStorage/PersistentStorageLocalFS.ts index e74923e38..92407c21a 100644 --- a/src/components/persistentStorage/PersistentStorageLocalFS.ts +++ b/src/components/persistentStorage/PersistentStorageLocalFS.ts @@ -251,7 +251,7 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { async getFileChecksum( bucketId: string, fileName: string, - consumerAddress: string + consumerAddress?: string ): Promise { await this.ensureBucketExists(bucketId) // file checksum can be obtained without consumerAddress, but if provided, it will be validated for access. From 53ce3ef62b26e56c13a33d7c6751b24d3ec28778 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Sat, 20 Jun 2026 10:53:19 +0300 Subject: [PATCH 10/10] make consumerAddress optional --- src/components/persistentStorage/PersistentStorageFactory.ts | 2 +- src/components/persistentStorage/PersistentStorageS3.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index 306d771c7..fbd85c7de 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -195,7 +195,7 @@ export abstract class PersistentStorageFactory { public abstract getFileChecksum( bucketId: string, fileName: string, - consumerAddress: string + consumerAddress?: string ): Promise /** diff --git a/src/components/persistentStorage/PersistentStorageS3.ts b/src/components/persistentStorage/PersistentStorageS3.ts index ceac072e3..155c5a9ab 100644 --- a/src/components/persistentStorage/PersistentStorageS3.ts +++ b/src/components/persistentStorage/PersistentStorageS3.ts @@ -97,7 +97,7 @@ export class PersistentStorageS3 extends PersistentStorageFactory { async getFileChecksum( _bucketId: string, _fileName: string, - _consumerAddress: string + _consumerAddress?: string ): Promise { throw new Error('PersistentStorageS3 is not implemented yet') }