From f517ccf21727716ef7bcbe55787a36dac6dd1ea2 Mon Sep 17 00:00:00 2001 From: paologaleotti <45665769+paologaleotti@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:48:08 +0200 Subject: [PATCH] IMN-616 BFF Tools router (#976) Co-authored-by: Stefano Hu <76391491+shuyec@users.noreply.github.com> Co-authored-by: Roberto Taglioni Co-authored-by: Carmine Porricelli --- packages/backend-for-frontend/Dockerfile | 3 + packages/backend-for-frontend/package.json | 1 + packages/backend-for-frontend/src/app.ts | 2 +- .../src/clients/clientsProvider.ts | 4 + .../backend-for-frontend/src/model/errors.ts | 65 +++ .../src/routers/toolRouter.ts | 39 +- .../src/services/toolService.ts | 423 ++++++++++++++++++ .../src/utilities/errorMappers.ts | 5 + .../client-assertion-validation/src/types.ts | 8 +- .../client-assertion-validation/src/utils.ts | 2 +- pnpm-lock.yaml | 15 +- 11 files changed, 546 insertions(+), 21 deletions(-) create mode 100644 packages/backend-for-frontend/src/services/toolService.ts diff --git a/packages/backend-for-frontend/Dockerfile b/packages/backend-for-frontend/Dockerfile index 0ed54e7c7e..807bfd6854 100644 --- a/packages/backend-for-frontend/Dockerfile +++ b/packages/backend-for-frontend/Dockerfile @@ -12,6 +12,7 @@ COPY ./packages/commons/package.json /app/packages/commons/package.json COPY ./packages/models/package.json /app/packages/models/package.json COPY ./packages/api-clients/package.json /app/packages/api-clients/package.json COPY ./packages/agreement-lifecycle/package.json /app/packages/agreement-lifecycle/package.json +COPY ./packages/client-assertion-validation/package.json /app/packages/client-assertion-validation/package.json RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile @@ -22,6 +23,7 @@ COPY ./packages/commons /app/packages/commons COPY ./packages/models /app/packages/models COPY ./packages/api-clients /app/packages/api-clients COPY ./packages/agreement-lifecycle /app/packages/agreement-lifecycle +COPY ./packages/client-assertion-validation /app/packages/client-assertion-validation RUN pnpm build && \ rm -rf /app/node_modules/.modules.yaml && \ @@ -34,6 +36,7 @@ RUN pnpm build && \ packages/models \ packages/api-clients \ packages/agreement-lifecycle \ + packages/client-assertion-validation \ packages/backend-for-frontend/dist && \ find /out -exec touch -h --date=@0 {} \; diff --git a/packages/backend-for-frontend/package.json b/packages/backend-for-frontend/package.json index 96a873909e..02f8500104 100644 --- a/packages/backend-for-frontend/package.json +++ b/packages/backend-for-frontend/package.json @@ -45,6 +45,7 @@ "adm-zip": "0.5.15", "mime": "4.0.4", "pagopa-interop-agreement-lifecycle": "workspace:*", + "pagopa-interop-client-assertion-validation": "workspace:*", "pagopa-interop-api-clients": "workspace:*", "pagopa-interop-commons": "workspace:*", "pagopa-interop-models": "workspace:*", diff --git a/packages/backend-for-frontend/src/app.ts b/packages/backend-for-frontend/src/app.ts index 130f4f5839..7bb8946839 100644 --- a/packages/backend-for-frontend/src/app.ts +++ b/packages/backend-for-frontend/src/app.ts @@ -76,7 +76,7 @@ app.use( agreementRouter(zodiosCtx, clients, fileManager), selfcareRouter(clients, zodiosCtx), supportRouter(zodiosCtx, clients, redisRateLimiter), - toolRouter(zodiosCtx), + toolRouter(zodiosCtx, clients), tenantRouter(zodiosCtx, clients), clientRouter(zodiosCtx, clients), privacyNoticeRouter(zodiosCtx), diff --git a/packages/backend-for-frontend/src/clients/clientsProvider.ts b/packages/backend-for-frontend/src/clients/clientsProvider.ts index 0d105d9c79..1c5bd07948 100644 --- a/packages/backend-for-frontend/src/clients/clientsProvider.ts +++ b/packages/backend-for-frontend/src/clients/clientsProvider.ts @@ -38,6 +38,7 @@ export type AuthorizationProcessClient = { typeof authorizationApi.createProducerKeychainApiClient >; user: ReturnType; + token: ReturnType; }; export type SelfcareV2Client = { @@ -81,6 +82,9 @@ export function getInteropBeClients(): PagoPAInteropBeClients { config.authorizationUrl ), user: authorizationApi.createUserApiClient(config.authorizationUrl), + token: authorizationApi.createTokenGenerationApiClient( + config.authorizationUrl + ), }, selfcareV2Client: { institution: selfcareV2InstitutionClientBuilder(config), diff --git a/packages/backend-for-frontend/src/model/errors.ts b/packages/backend-for-frontend/src/model/errors.ts index 7376f2505a..2b6970754a 100644 --- a/packages/backend-for-frontend/src/model/errors.ts +++ b/packages/backend-for-frontend/src/model/errors.ts @@ -42,6 +42,12 @@ export const errorCodes = { privacyNoticeNotFoundInConfiguration: "0033", privacyNoticeNotFound: "0034", privacyNoticeVersionIsNotTheLatest: "0035", + missingActivePurposeVersion: "0036", + activeAgreementByEserviceAndConsumerNotFound: "0037", + purposeIdNotFoundInClientAssertion: "0038", + clientAssertionPublicKeyNotFound: "0049", + organizationNotAllowed: "0040", + cannotGetKeyWithClient: "0041", }; export type ErrorCodes = keyof typeof errorCodes; @@ -352,3 +358,62 @@ export function invalidZipStructure(description: string): ApiError { title: "Invalid zip structure", }); } + +export function missingActivePurposeVersion( + purposeId: string +): ApiError { + return new ApiError({ + detail: `There is no active version for purpose ${purposeId}`, + code: "missingActivePurposeVersion", + title: "Missing active purpose version", + }); +} + +export function activeAgreementByEserviceAndConsumerNotFound( + eserviceId: string, + consumerId: string +): ApiError { + return new ApiError({ + detail: `Active agreement for Eservice ${eserviceId} and consumer ${consumerId} not found`, + code: "activeAgreementByEserviceAndConsumerNotFound", + title: "Active agreement not found", + }); +} + +export function purposeIdNotFoundInClientAssertion(): ApiError { + return new ApiError({ + detail: `PurposeId not found in client assertion`, + code: "purposeIdNotFoundInClientAssertion", + title: "PurposeId not found in client assertion", + }); +} + +export function clientAssertionPublicKeyNotFound( + kid: string, + clientId: string +): ApiError { + return new ApiError({ + detail: `Public key with kid ${kid} not found for client ${clientId}`, + code: "clientAssertionPublicKeyNotFound", + title: "Client assertion public key not found", + }); +} + +export function organizationNotAllowed(clientId: string): ApiError { + return new ApiError({ + detail: `Organization not allowed for client ${clientId}`, + code: "organizationNotAllowed", + title: "Organization not allowed", + }); +} + +export function cannotGetKeyWithClient( + clientId: string, + keyId: string +): ApiError { + return new ApiError({ + detail: `Cannot get key with client ${clientId} and key ${keyId}`, + code: "cannotGetKeyWithClient", + title: "Cannot get key with client", + }); +} diff --git a/packages/backend-for-frontend/src/routers/toolRouter.ts b/packages/backend-for-frontend/src/routers/toolRouter.ts index 1ca13f2e5c..7a9da13ff5 100644 --- a/packages/backend-for-frontend/src/routers/toolRouter.ts +++ b/packages/backend-for-frontend/src/routers/toolRouter.ts @@ -1,22 +1,51 @@ import { ZodiosEndpointDefinitions } from "@zodios/core"; import { ZodiosRouter } from "@zodios/express"; -import { bffApi } from "pagopa-interop-api-clients"; import { ExpressContext, ZodiosContext, zodiosValidationErrorToApiProblem, } from "pagopa-interop-commons"; +import { bffApi } from "pagopa-interop-api-clients"; +import { toolsServiceBuilder } from "../services/toolService.js"; +import { fromBffAppContext } from "../utilities/context.js"; +import { toolsErrorMapper } from "../utilities/errorMappers.js"; +import { PagoPAInteropBeClients } from "../clients/clientsProvider.js"; +import { makeApiProblem } from "../model/errors.js"; const toolRouter = ( - ctx: ZodiosContext + ctx: ZodiosContext, + clients: PagoPAInteropBeClients ): ZodiosRouter => { const toolRouter = ctx.router(bffApi.toolsApi.api, { validationErrorHandler: zodiosValidationErrorToApiProblem, }); - toolRouter.post("/tools/validateTokenGeneration", async (_req, res) => - res.status(501).send() - ); + const toolsService = toolsServiceBuilder(clients); + + toolRouter.post("/tools/validateTokenGeneration", async (req, res) => { + const ctx = fromBffAppContext(req.ctx, req.headers); + + try { + const result = await toolsService.validateTokenGeneration( + req.body.client_id, + req.body.client_assertion, + req.body.client_assertion_type, + req.body.grant_type, + ctx + ); + return res + .status(200) + .send(bffApi.TokenGenerationValidationResult.parse(result)); + } catch (error) { + const errorRes = makeApiProblem( + error, + toolsErrorMapper, + ctx.logger, + "Error validating token generation request" + ); + return res.status(errorRes.status).send(errorRes); + } + }); return toolRouter; }; diff --git a/packages/backend-for-frontend/src/services/toolService.ts b/packages/backend-for-frontend/src/services/toolService.ts new file mode 100644 index 0000000000..58eedd1183 --- /dev/null +++ b/packages/backend-for-frontend/src/services/toolService.ts @@ -0,0 +1,423 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { isAxiosError } from "axios"; +import { + ApiKey, + ClientAssertion, + ConsumerKey, + FailedValidation, + SuccessfulValidation, + validateClientKindAndPlatformState, + validateRequestParameters, + verifyClientAssertion, + verifyClientAssertionSignature, +} from "pagopa-interop-client-assertion-validation"; +import { + AgreementId, + ApiError, + ClientId, + EServiceId, + ItemState, + PurposeId, + TenantId, + unsafeBrandId, +} from "pagopa-interop-models"; +import { WithLogger } from "pagopa-interop-commons"; +import { + agreementApi, + authorizationApi, + bffApi, + catalogApi, + purposeApi, +} from "pagopa-interop-api-clients"; +import { BffAppContext } from "../utilities/context.js"; +import { + activeAgreementByEserviceAndConsumerNotFound, + cannotGetKeyWithClient, + clientAssertionPublicKeyNotFound, + ErrorCodes, + eserviceDescriptorNotFound, + missingActivePurposeVersion, + organizationNotAllowed, + purposeIdNotFoundInClientAssertion, +} from "../model/errors.js"; +import { + PagoPAInteropBeClients, + AgreementProcessClient, +} from "../clients/clientsProvider.js"; +import { getAllAgreements } from "./agreementService.js"; + +export function toolsServiceBuilder(clients: PagoPAInteropBeClients) { + return { + async validateTokenGeneration( + clientId: string | undefined, + clientAssertion: string, + clientAssertionType: string, + grantType: string, + ctx: WithLogger + ): Promise { + ctx.logger.info(`Validating token generation for client ${clientId}`); + + const { errors: parametersErrors } = validateRequestParameters({ + client_assertion: clientAssertion, + client_assertion_type: clientAssertionType, + grant_type: grantType, + client_id: clientId, + }); + + const { data: jwt, errors: clientAssertionErrors } = + verifyClientAssertion(clientAssertion, clientId); + + if (parametersErrors || clientAssertionErrors) { + return handleValidationResults({ + clientAssertionErrors: [ + ...(parametersErrors ?? []), + ...(clientAssertionErrors ?? []), + ], + }); + } + + const { data, errors: keyRetrieveErrors } = await retrieveKeyAndEservice( + clients, + jwt, + ctx + ); + if (keyRetrieveErrors) { + return handleValidationResults({ + keyRetrieveErrors, + }); + } + + const { key, eservice: keyEservice, descriptor: keyDescriptor } = data; + const eservice = + keyEservice && keyDescriptor + ? toTokenValidationEService(keyEservice, keyDescriptor) + : undefined; + + const { errors: clientSignatureErrors } = verifyClientAssertionSignature( + clientAssertion, + key + ); + if (clientSignatureErrors) { + return handleValidationResults( + { + clientSignatureErrors, + }, + key.clientKind, + eservice + ); + } + + const { errors: platformStateErrors } = + validateClientKindAndPlatformState(key, jwt); + if (platformStateErrors) { + return handleValidationResults( + { + platformStateErrors, + }, + key.clientKind, + eservice + ); + } + + return handleValidationResults({}, key.clientKind, eservice); + }, + }; +} + +export type ToolsService = ReturnType; + +function handleValidationResults( + errs: { + clientAssertionErrors?: Array>; + keyRetrieveErrors?: Array>; + clientSignatureErrors?: Array>; + platformStateErrors?: Array>; + }, + clientKind?: authorizationApi.ClientKind, + eservice?: bffApi.TokenGenerationValidationEService +): bffApi.TokenGenerationValidationResult { + const clientAssertionErrors = errs.clientAssertionErrors ?? []; + const keyRetrieveErrors = errs.keyRetrieveErrors ?? []; + const clientSignatureErrors = errs.clientSignatureErrors ?? []; + const platformStateErrors = errs.platformStateErrors ?? []; + + return { + clientKind, + eservice, + steps: { + clientAssertionValidation: { + result: getStepResult([], clientAssertionErrors), + failures: apiErrorsToValidationFailures(clientAssertionErrors), + }, + publicKeyRetrieve: { + result: getStepResult(clientAssertionErrors, keyRetrieveErrors), + failures: apiErrorsToValidationFailures(keyRetrieveErrors), + }, + clientAssertionSignatureVerification: { + result: getStepResult( + [...clientAssertionErrors, ...keyRetrieveErrors], + clientSignatureErrors + ), + failures: apiErrorsToValidationFailures(clientSignatureErrors), + }, + platformStatesVerification: { + result: getStepResult( + [ + ...clientAssertionErrors, + ...keyRetrieveErrors, + ...clientSignatureErrors, + ], + platformStateErrors + ), + failures: apiErrorsToValidationFailures(platformStateErrors), + }, + }, + }; +} + +function assertIsConsumer( + requesterId: string, + keyWithClient: authorizationApi.KeyWithClient +) { + if (requesterId !== keyWithClient.client.consumerId) { + throw organizationNotAllowed(keyWithClient.client.id); + } +} + +function getStepResult( + prevStepErrors: Array>, + currentStepErrors: Array> +): bffApi.TokenGenerationValidationStepResult { + if (currentStepErrors.length > 0) { + return bffApi.TokenGenerationValidationStepResult.Enum.FAILED; + } else if (prevStepErrors.length > 0) { + return bffApi.TokenGenerationValidationStepResult.Enum.SKIPPED; + } else { + return bffApi.TokenGenerationValidationStepResult.Enum.PASSED; + } +} + +async function retrieveKeyAndEservice( + { + authorizationClient, + purposeProcessClient, + agreementProcessClient, + catalogProcessClient, + }: PagoPAInteropBeClients, + jwt: ClientAssertion, + ctx: WithLogger +): Promise< + | SuccessfulValidation<{ + key: ApiKey | ConsumerKey; + eservice?: catalogApi.EService; + descriptor?: catalogApi.EServiceDescriptor; + }> + | FailedValidation +> { + const keyWithClient = await authorizationClient.token + .getKeyWithClientByKeyId({ + params: { + clientId: jwt.payload.sub, + keyId: jwt.header.kid, + }, + headers: ctx.headers, + }) + .catch((e) => { + if (isAxiosError(e) && e.response?.status === 404) { + return undefined; + } + throw cannotGetKeyWithClient(jwt.payload.sub, jwt.header.kid); + }); + + if (!keyWithClient) { + return { + data: undefined, + errors: [ + clientAssertionPublicKeyNotFound(jwt.header.kid, jwt.payload.sub), + ], + }; + } + + assertIsConsumer(ctx.authData.organizationId, keyWithClient); + + const { encodedPem, algorithm } = + await authorizationClient.client.getClientKeyById({ + headers: ctx.headers, + params: { + clientId: keyWithClient.client.id, + keyId: jwt.header.kid, + }, + }); + + if (keyWithClient.client.kind === authorizationApi.ClientKind.enum.API) { + return { + errors: undefined, + data: { + key: { + clientKind: authorizationApi.ClientKind.enum.API, + kid: jwt.header.kid, + algorithm, + publicKey: encodedPem, + clientId: unsafeBrandId(keyWithClient.client.id), + consumerId: unsafeBrandId(keyWithClient.client.consumerId), + }, + }, + }; + } + + if (!jwt.payload.purposeId) { + return { + data: undefined, + errors: [purposeIdNotFoundInClientAssertion()], + }; + } + const purposeId = unsafeBrandId(jwt.payload.purposeId); + + const purpose = await purposeProcessClient.getPurpose({ + params: { id: purposeId }, + headers: ctx.headers, + }); + + const agreement = await retrieveAgreement( + agreementProcessClient, + purpose.consumerId, + purpose.eserviceId, + ctx + ); + + const eservice = await catalogProcessClient.getEServiceById({ + params: { eServiceId: agreement.eserviceId }, + headers: ctx.headers, + }); + + const descriptor = await retrieveDescriptor(eservice, agreement.eserviceId); + + return { + errors: undefined, + data: { + key: { + clientKind: authorizationApi.ClientKind.enum.CONSUMER, + clientId: unsafeBrandId(keyWithClient.client.id), + kid: jwt.header.kid, + algorithm, + publicKey: encodedPem, + purposeId, + consumerId: unsafeBrandId(keyWithClient.client.consumerId), + agreementId: unsafeBrandId(agreement.id), + eServiceId: unsafeBrandId(agreement.eserviceId), + agreementState: agreementStateToItemState(agreement.state), + purposeState: retrievePurposeItemState(purpose), + descriptorState: descriptorStateToItemState(descriptor.state), + }, + eservice, + descriptor, + }, + }; +} + +async function retrieveAgreement( + agreementClient: AgreementProcessClient, + consumerId: string, + eserviceId: string, + ctx: WithLogger +): Promise { + const agreements = await getAllAgreements(agreementClient, ctx.headers, { + consumersIds: [consumerId], + eservicesIds: [eserviceId], + states: [ + agreementApi.AgreementState.Values.ACTIVE, + agreementApi.AgreementState.Values.SUSPENDED, + agreementApi.AgreementState.Values.ARCHIVED, + ], + }); + + if (agreements.length === 0) { + throw activeAgreementByEserviceAndConsumerNotFound(eserviceId, consumerId); + } + if (agreements.length === 1) { + return agreements[0]; + } + + // If there are multiple agreements, give priority to active or suspended agreement + const agreementPrioritized = agreements.find( + (a) => + a.state === agreementApi.AgreementState.Values.SUSPENDED || + a.state === agreementApi.AgreementState.Values.ACTIVE + ); + return agreementPrioritized ?? agreements[0]; +} + +async function retrieveDescriptor( + eservice: catalogApi.EService, + descriptorId: string +): Promise { + const descriptor = eservice.descriptors.find((d) => d.id === descriptorId); + if (!descriptor) { + throw eserviceDescriptorNotFound(eservice.id, descriptorId); + } + + return descriptor; +} + +function retrievePurposeItemState(purpose: purposeApi.Purpose): ItemState { + const purposeVersion = [...purpose.versions] + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ) + .find( + (v) => + v.state === purposeApi.PurposeVersionState.Enum.ACTIVE || + v.state === purposeApi.PurposeVersionState.Enum.SUSPENDED || + v.state === purposeApi.PurposeVersionState.Enum.ARCHIVED + ); + + if (!purposeVersion) { + throw missingActivePurposeVersion(purpose.id); + } + + return purposeVersion.state === purposeApi.PurposeVersionState.Enum.ACTIVE + ? ItemState.Enum.ACTIVE + : ItemState.Enum.INACTIVE; +} + +function toTokenValidationEService( + eservice: catalogApi.EService, + descriptor: catalogApi.EServiceDescriptor +): bffApi.TokenGenerationValidationEService { + return { + descriptorId: descriptor.id, + id: eservice.id, + name: eservice.name, + version: descriptor.version, + }; +} + +const agreementStateToItemState = ( + state: agreementApi.AgreementState +): ItemState => + state === agreementApi.AgreementState.Values.ACTIVE + ? ItemState.Enum.ACTIVE + : ItemState.Enum.INACTIVE; + +const descriptorStateToItemState = ( + state: catalogApi.EServiceDescriptorState +): ItemState => + state === catalogApi.EServiceDescriptorState.Enum.PUBLISHED || + state === catalogApi.EServiceDescriptorState.Enum.DEPRECATED + ? ItemState.Enum.ACTIVE + : ItemState.Enum.INACTIVE; + +function apiErrorsToValidationFailures( + errors: Array> | undefined +): bffApi.TokenGenerationValidationStepFailure[] { + if (!errors) { + return []; + } + + return errors.map((err) => ({ + code: err.code, + reason: err.message, + })); +} diff --git a/packages/backend-for-frontend/src/utilities/errorMappers.ts b/packages/backend-for-frontend/src/utilities/errorMappers.ts index 444644cb1a..55bc8557ef 100644 --- a/packages/backend-for-frontend/src/utilities/errorMappers.ts +++ b/packages/backend-for-frontend/src/utilities/errorMappers.ts @@ -155,6 +155,11 @@ export const getProducerKeychainUsersErrorMapper = ( .with("userNotFound", () => HTTP_STATUS_NOT_FOUND) .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); +export const toolsErrorMapper = (error: ApiError): number => + match(error.code) + .with("organizationNotAllowed", () => HTTP_STATUS_FORBIDDEN) + .otherwise(() => HTTP_STATUS_INTERNAL_SERVER_ERROR); + export const createEServiceDocumentErrorMapper = ( error: ApiError ): number => diff --git a/packages/client-assertion-validation/src/types.ts b/packages/client-assertion-validation/src/types.ts index 5b14a77973..a00ed0682f 100644 --- a/packages/client-assertion-validation/src/types.ts +++ b/packages/client-assertion-validation/src/types.ts @@ -77,11 +77,13 @@ export const ApiKey = Key.extend({ }).strict(); export type ApiKey = z.infer; -export type ValidationResult = SuccessfulValidation | FailedValidation; +export type ValidationResult = + | SuccessfulValidation + | FailedValidation; export type SuccessfulValidation = { errors: undefined; data: T }; -export type FailedValidation = { - errors: Array>; +export type FailedValidation = { + errors: Array>; data: undefined; }; diff --git a/packages/client-assertion-validation/src/utils.ts b/packages/client-assertion-validation/src/utils.ts index b520ba2641..7fc74f60d4 100644 --- a/packages/client-assertion-validation/src/utils.ts +++ b/packages/client-assertion-validation/src/utils.ts @@ -203,7 +203,7 @@ export const failedValidation = ( errors: Array< Array | undefined> | ApiError | undefined > -): FailedValidation => { +): FailedValidation => { const nestedArrayWithoutUndefined = errors.filter((a) => a !== undefined); const flattenedArray = nestedArrayWithoutUndefined.flat(1); const flattenedArrayWithoutUndefined = flattenedArray.filter( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b7b628213..97010e38d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,7 +321,7 @@ importers: dependencies: csv: specifier: ^6.3.2 - version: 6.3.10 + version: 6.3.2 dotenv-flow: specifier: 4.1.0 version: 4.1.0 @@ -821,6 +821,9 @@ importers: pagopa-interop-api-clients: specifier: workspace:* version: link:../api-clients + pagopa-interop-client-assertion-validation: + specifier: workspace:* + version: link:../client-assertion-validation pagopa-interop-commons: specifier: workspace:* version: link:../commons @@ -8993,16 +8996,6 @@ packages: resolution: {integrity: sha512-+9lpZfwpLntpTIEpFbwQyWuW/hmI/eHuJZD1XzeZpfZTqkf1fyvBbBLXTJJMsBuuS11uTShMqPwzx4A6ffXgRQ==} dev: false - /csv@6.3.10: - resolution: {integrity: sha512-5NYZG4AN2ZUthmNxIudgBEdMPUnbQHu9V4QTzBPqQzUP3KQsFiJo+8HQ0+oVxj1PomIT1/f67VI1QH/hsrZLKA==} - engines: {node: '>= 0.1.90'} - dependencies: - csv-generate: 4.4.1 - csv-parse: 5.5.6 - csv-stringify: 6.5.1 - stream-transform: 3.3.2 - dev: false - /csv@6.3.2: resolution: {integrity: sha512-fOm1LBmt4/kjC1RFanNtjSFVjvoh6MS5E/CuQrED5gCfvjHESZD97Fbjfz/W8ZN4wQAxFjzOonATE790UIuLTg==} engines: {node: '>= 0.1.90'}