diff --git a/packages/byods/jest.config.js b/packages/byods/jest.config.js index 70be0abdfce..7a63b38ae69 100644 --- a/packages/byods/jest.config.js +++ b/packages/byods/jest.config.js @@ -4,8 +4,8 @@ const jestConfig = { rootDir: './', setupFilesAfterEnv: ['/jest.setup.js'], testEnvironment: 'node', - testMatch: ['/**/*.test.ts'], - transformIgnorePatterns: ['/node_modules/(?!node-fetch)|data-uri-to-buffer'], + testMatch: ['/test/unit/spec/**/*.ts'], + transformIgnorePatterns: [], testPathIgnorePatterns: ['/node_modules/', '/dist/'], testResultsProcessor: 'jest-junit', // Clear mocks in between tests by default diff --git a/packages/byods/package.json b/packages/byods/package.json index 1d2567c3ce5..e5c09d224fe 100644 --- a/packages/byods/package.json +++ b/packages/byods/package.json @@ -45,6 +45,8 @@ "@typescript-eslint/eslint-plugin": "5.38.1", "@typescript-eslint/parser": "5.38.1", "@web/dev-server": "0.4.5", + "@webex/jest-config-legacy": "workspace:*", + "@webex/legacy-tools": "workspace:*", "chai": "4.3.4", "cspell": "5.19.2", "esbuild": "^0.17.19", @@ -114,11 +116,9 @@ "noprompt": true }, "dependencies": { - "@types/node-fetch": "^2.6.11", - "@webex/jest-config-legacy": "workspace:*", - "@webex/legacy-tools": "workspace:*", - "@webex/media-helpers": "workspace:*", - "node-fetch": "^3.3.2" + "@types/node-fetch": "2.6.11", + "jose": "5.8.0", + "node-fetch": "3.3.2" }, "type": "module" } diff --git a/packages/byods/src/BYODS.ts b/packages/byods/src/BYODS.ts deleted file mode 100644 index 1a3d25ff304..00000000000 --- a/packages/byods/src/BYODS.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable no-console */ -import fetch from 'node-fetch'; - -interface SDKConfig { - clientId: string; - clientSecret: string; - accessToken: string; - refreshToken: string; - expiresAt: Date; -} - -class BYODS { - private clientId: string; - private clientSecret: string; - private tokenHost: string; - private accessToken: string; - private refreshToken: string; - private expiresAt: Date; - - constructor({clientId, clientSecret, accessToken, refreshToken, expiresAt}: SDKConfig) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.expiresAt = expiresAt; - this.tokenHost = 'https://webexapis.com/v1/access_token'; - } - - public async makeAuthenticatedRequest(endpoint: string): Promise { - if (new Date() >= new Date(this.expiresAt as Date)) { - throw new Error('Token has expired'); - } - - // Use this.token.access_token to make authenticated requests - const response = await fetch(endpoint, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - }, - }); - - return response.json(); - } -} - -export default BYODS; -export {SDKConfig}; diff --git a/packages/byods/src/BYoDS.test.ts b/packages/byods/src/BYoDS.test.ts deleted file mode 100644 index a46208c481f..00000000000 --- a/packages/byods/src/BYoDS.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import fetch, {Response} from 'node-fetch'; -import BYODS from './BYODS'; - -jest.mock('node-fetch', () => jest.fn()); - -describe('BYoDS Tests', () => { - const mockSDKConfig = { - clientId: 'your-client-id', - clientSecret: 'your-client-secret', - accessToken: 'your-initial-access-token', - refreshToken: 'your-refresh-token', - expiresAt: new Date('2024-09-15T00:00:00Z'), - }; - - const mockResponse = { - json: jest.fn().mockResolvedValue({key: 'value'}), - } as unknown as Response; - - (fetch as unknown as jest.MockedFunction).mockResolvedValue(mockResponse); - - it('fetch the datasources', async () => { - const mockPayload = { - headers: { - Authorization: `Bearer ${mockSDKConfig.accessToken}`, - }, - }; - const sdk = new BYODS(mockSDKConfig); - const endpoint = 'https://developer-applications.ciscospark.com/v1/dataSources/'; - await sdk.makeAuthenticatedRequest(endpoint); - expect(fetch).toHaveBeenCalledWith(endpoint, mockPayload); - }); -}); diff --git a/packages/byods/src/apiClient.ts b/packages/byods/src/apiClient.ts deleted file mode 100644 index 282b5126342..00000000000 --- a/packages/byods/src/apiClient.ts +++ /dev/null @@ -1,28 +0,0 @@ -import BYODS, {SDKConfig} from './BYODS'; - -const config: SDKConfig = { - clientId: 'your-client-id', - clientSecret: 'your-client-secret', - accessToken: 'your-initial-access-token', - refreshToken: 'your-refresh-token', - expiresAt: new Date('2024-09-15T00:00:00Z'), -}; -const sdk = new BYODS(config); - -// This function is just a placeholder to test project setup. -async function listDataSources() { - await sdk.makeAuthenticatedRequest( - 'https://developer-applications.ciscospark.com/v1/dataSources/' - ); -} - -// This function is just a placeholder to test project setup. -async function getDataSources(id: string) { - await sdk.makeAuthenticatedRequest( - `https://developer-applications.ciscospark.com/v1/dataSources/${id}` - ); -} - -export {listDataSources, getDataSources}; - -export default listDataSources; diff --git a/packages/byods/src/base-client/index.ts b/packages/byods/src/base-client/index.ts new file mode 100644 index 00000000000..12b10dca82f --- /dev/null +++ b/packages/byods/src/base-client/index.ts @@ -0,0 +1,201 @@ +import fetch, {Response, RequestInit} from 'node-fetch'; + +import TokenManager from '../token-manager'; +import DataSourceClient from '../data-source-client'; +import {HttpClient, ApiResponse} from '../http-client/types'; + +export default class BaseClient { + private baseUrl: string; + private headers: Record; + private tokenManager: TokenManager; + private orgId: string; + + public dataSource: DataSourceClient; + + /** + * Creates an instance of BaseClient. + * @param {string} baseUrl - The base URL for the API. + * @param {Record} headers - The additional headers to be used in requests. + * @param {TokenManager} tokenManager - The token manager instance. + * @param {string} orgId - The organization ID. + * @example + * const client = new BaseClient('https://webexapis.com/v1', { 'Your-Custom-Header': 'some value' }, tokenManager, 'org123'); + */ + constructor( + baseUrl: string, + headers: Record, + tokenManager: TokenManager, + orgId: string + ) { + this.baseUrl = baseUrl; + this.headers = headers; + this.tokenManager = tokenManager; + this.orgId = orgId; + this.dataSource = new DataSourceClient(this.getHttpClientForOrg()); + } + + /** + * Makes an HTTP request. + * @param {string} endpoint - The API endpoint. + * @param {RequestInit} [options=\{\}] - The request options. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.request('/endpoint', { method: 'GET', headers: {} }); + */ + public async request(endpoint: string, options: RequestInit = {}): Promise> { + const url = `${this.baseUrl}${endpoint}`; + const token = await this.getToken(); + + const response: Response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + ...this.headers, + ...options.headers, + }, + }); + + const data: any = await response.json(); + if (!response.ok) { + throw new Error(`Error: ${response.status} - ${data.message}`); + } + + return {data, status: response.status}; + } + + /** + * Makes a POST request. + * @param {string} endpoint - The API endpoint. + * @param {Record} body - The request body. + * @param {Record} [headers=\{\}] - The request headers. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.post('/endpoint', { key: 'value' }); + */ + public async post( + endpoint: string, + body: Record, + headers: Record = {} + ): Promise> { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(body), + headers: {'Content-Type': 'application/json', ...headers}, + }); + } + + /** + * Makes a PUT request. + * @param {string} endpoint - The API endpoint. + * @param {Record} body - The request body. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.put('/endpoint', { key: 'value' }); + */ + public async put( + endpoint: string, + body: Record, + headers: Record = {} + ): Promise> { + return this.request(endpoint, { + method: 'PUT', + body: JSON.stringify(body), + headers: {'Content-Type': 'application/json', ...headers}, + }); + } + + /** + * Makes a PATCH request. + * @param {string} endpoint - The API endpoint. + * @param {Record} body - The request body. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.patch('/endpoint', { key: 'value' }); + */ + public async patch( + endpoint: string, + body: Record, + headers: Record = {} + ): Promise> { + return this.request(endpoint, { + method: 'PATCH', + body: JSON.stringify(body), + headers: {'Content-Type': 'application/json', ...headers}, + }); + } + + /** + * Makes a GET request. + * @param {string} endpoint - The API endpoint. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.get('/endpoint'); + */ + public async get( + endpoint: string, + headers: Record = {} + ): Promise> { + return this.request(endpoint, { + method: 'GET', + headers, + }); + } + + /** + * Makes a DELETE request. + * @param {string} endpoint - The API endpoint. + * @returns {Promise>} - The API response. + * @template T + * @example + * const response = await client.delete('/endpoint'); + */ + public async delete( + endpoint: string, + headers: Record = {} + ): Promise> { + return this.request(endpoint, { + method: 'DELETE', + headers, + }); + } + + /** + * Get an HTTP client for a specific organization. + * @returns {HttpClient} - An object containing methods for making HTTP requests. + * @example + * const httpClient = client.getHttpClientForOrg(); + * const response = await httpClient.get('/endpoint'); + */ + public getHttpClientForOrg(): HttpClient { + return { + get: (endpoint: string) => this.get(endpoint), + delete: (endpoint: string) => this.delete(endpoint), + post: (endpoint: string, body: Record) => this.post(endpoint, body), + put: (endpoint: string, body: Record) => this.put(endpoint, body), + patch: (endpoint: string, body: Record) => this.patch(endpoint, body), + }; + } + + private async getToken(): Promise { + const serviceAppAuthorization = await this.tokenManager.getOrgServiceAppAuthorization( + this.orgId + ); + let token = serviceAppAuthorization.serviceAppToken.accessToken; + + if (new Date() >= new Date(serviceAppAuthorization.serviceAppToken.expiresAt)) { + await this.tokenManager.refreshServiceAppAccessToken(this.orgId, this.headers); + const refreshedAuthorization = await this.tokenManager.getOrgServiceAppAuthorization( + this.orgId + ); + token = refreshedAuthorization.serviceAppToken.accessToken; + } + // TODO: Handle refresh token expiration + + return token; + } +} diff --git a/packages/byods/src/byods/index.ts b/packages/byods/src/byods/index.ts new file mode 100644 index 00000000000..7f2996ad557 --- /dev/null +++ b/packages/byods/src/byods/index.ts @@ -0,0 +1,91 @@ +import {jwksCache, createRemoteJWKSet, JWKSCacheInput} from 'jose'; + +import BaseClient from '../base-client'; +import { + USER_AGENT, + PRODUCTION_JWKS_URL, + INTEGRATION_JWKS_URL, + PRODUCTION_BASE_URL, + INTEGRATION_BASE_URL, +} from '../constants'; +import {SDKConfig} from '../types'; +import TokenManager from '../token-manager'; + +/** + * The BYoDS SDK. + */ +export default class BYODS { + private headers: Record = { + 'User-Agent': USER_AGENT, + }; + + private jwksCache: JWKSCacheInput = {}; + private jwks: any; // No defined interface for return type of createRemoteJWKSet + private env: 'production' | 'integration'; + private config: SDKConfig; + private baseUrl: string; + + /** + * The token manager for the SDK. + */ + public tokenManager: TokenManager; + + /** + * Constructs a new instance of the BYODS SDK. + * + * @param {SDKConfig} config - The configuration object containing clientId and clientSecret. + * @example + * const sdk = new BYODS({ clientId: 'your-client-id', clientSecret: 'your-client-secret' }); + */ + constructor({clientId, clientSecret}: SDKConfig) { + this.config = {clientId, clientSecret}; + this.tokenManager = new TokenManager(clientId, clientSecret); + + /** + * The environment variable `process.env.BYODS_ENVIRONMENT` determines the environment in which the SDK operates. + * It can be set to either 'production' or 'integration'. If not set, it defaults to 'production'. + */ + const parsedEnv = process.env.BYODS_ENVIRONMENT || 'production'; + let jwksUrl = PRODUCTION_BASE_URL; + + switch (parsedEnv) { + case 'production': + this.env = 'production'; + this.baseUrl = PRODUCTION_BASE_URL; + jwksUrl = PRODUCTION_JWKS_URL; + break; + case 'integration': + this.env = 'integration'; + this.baseUrl = INTEGRATION_BASE_URL; + jwksUrl = INTEGRATION_JWKS_URL; + break; + default: + this.env = 'production'; + this.baseUrl = PRODUCTION_BASE_URL; + jwksUrl = PRODUCTION_JWKS_URL; + } + + // Create a remote JWK Set + this.jwks = createRemoteJWKSet(new URL(jwksUrl), { + [jwksCache]: this.jwksCache, + cacheMaxAge: 600000, // 10 minutes + cooldownDuration: 30000, // 30 seconds + }); + } + + /** + * Retrieves a client instance for a specific organization. + * + * @param {string} orgId - The unique identifier of the organization. + * @returns {BaseClient} A new instance of BaseClient configured for the specified organization. + * @example + * const client = sdk.getClientForOrg('org-id'); + */ + public getClientForOrg(orgId: string): BaseClient { + if (!orgId) { + throw new Error(`orgId is required`); + } + + return new BaseClient(this.baseUrl, this.headers, this.tokenManager, orgId); + } +} diff --git a/packages/byods/src/constants.ts b/packages/byods/src/constants.ts new file mode 100644 index 00000000000..8b76cab1ea5 --- /dev/null +++ b/packages/byods/src/constants.ts @@ -0,0 +1,10 @@ +export const BYODS_FILE = 'BYODS'; +export const BYODS_SDK_VERSION = '0.0.1'; +export const BYODS_PACKAGE_NAME = 'BYoDS NodeJS SDK'; +export const USER_AGENT = `${BYODS_PACKAGE_NAME}/${BYODS_SDK_VERSION}`; +export const PRODUCTION_BASE_URL = 'https://webexapis.com/v1'; +export const INTEGRATION_BASE_URL = 'https://integration.webexapis.com/v1'; +export const PRODUCTION_JWKS_URL = 'https://idbroker.webex.com/idb/oauth2/v2/keys/verificationjwk'; +export const INTEGRATION_JWKS_URL = + 'https://idbrokerbts.webex.com/idb/oauth2/v2/keys/verificationjwk'; +export const APPLICATION_ID_PREFIX = 'ciscospark://us/APPLICATION/'; diff --git a/packages/byods/src/data-source-client/constants.ts b/packages/byods/src/data-source-client/constants.ts new file mode 100644 index 00000000000..2a8462b19c4 --- /dev/null +++ b/packages/byods/src/data-source-client/constants.ts @@ -0,0 +1 @@ +export const DATASOURCE_ENDPOINT = '/dataSources'; diff --git a/packages/byods/src/data-source-client/index.ts b/packages/byods/src/data-source-client/index.ts new file mode 100644 index 00000000000..c1b5d7abd62 --- /dev/null +++ b/packages/byods/src/data-source-client/index.ts @@ -0,0 +1,89 @@ +import {DataSourceRequest, DataSourceResponse} from './types'; +import {DATASOURCE_ENDPOINT} from './constants'; +import {HttpClient, ApiResponse} from '../http-client/types'; + +/** + * Client for interacting with the /dataSource API. + */ +export default class DataSourceClient { + private httpClient: HttpClient; + + /** + * Creates an instance of DataSourceClient. + * @param {HttpClient} httpClient - The HttpClient instance to use for API requests. + * @example + * const httpClient = new HttpClient(); + * const client = new DataSourceClient(httpClient); + */ + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Creates a new data source. + * @param {DataSourceRequest} createDataSourceRequest - The request object for creating a data source. + * @returns {Promise>} - A promise that resolves to the API response containing the created data source. + * @example + * const request: DataSourceRequest = { name: 'New DataSource', url: 'https://mydatasource.com', schemaId: '123', audience: 'myaudience', subject: 'mysubject', nonce: 'uniqueNonce' }; + * const response = await client.create(request); + */ + public async create( + dataSourcePayload: DataSourceRequest + ): Promise> { + return this.httpClient.post(DATASOURCE_ENDPOINT, dataSourcePayload); // TODO: Move /dataSources to constants + } + + /** + * Retrieves a data source by ID. + * @param {string} id - The ID of the data source to retrieve. + * @returns {Promise>} - A promise that resolves to the API response containing the data source. + * @example + * const id = '123'; + * const response = await client.get(id); + */ + public async get(id: string): Promise> { + return this.httpClient.get(`${DATASOURCE_ENDPOINT}/${id}`); + } + + /** + * Lists all data sources. + * @returns {Promise>} - A promise that resolves to the API response containing the list of data sources. + * @example + * const response = await client.list(); + */ + public async list(): Promise> { + return this.httpClient.get(DATASOURCE_ENDPOINT); + } + + /** + * Updates a data source by ID. + * @param {string} id - The ID of the data source to update. + * @param {DataSourceRequest} updateDataSourceRequest - The request object for updating a data source. + * @returns {Promise>} - A promise that resolves to the API response containing the updated data source. + * @example + * const id = '123'; + * const request: DataSourceRequest = { name: 'Updated DataSource', url: 'https://mydatasource.com', schemaId: '123', audience: 'myaudience', subject: 'mysubject', nonce: 'uniqueNonce' }; + * const response = await client.update(id, request); + */ + public async update( + id: string, + dataSourcePayload: DataSourceRequest + ): Promise> { + return this.httpClient.put( + `${DATASOURCE_ENDPOINT}/${id}`, + dataSourcePayload + ); + } + + /** + * Deletes a data source by ID. + * @param {string} id - The ID of the data source to delete. + * @returns {Promise>} - A promise that resolves to the API response confirming the deletion. + * @example + * const id = '123'; + * const response = await client.delete(id); + */ + public async delete(id: string): Promise> { + return this.httpClient.delete(`${DATASOURCE_ENDPOINT}/${id}`); + } +} diff --git a/packages/byods/src/data-source-client/types.ts b/packages/byods/src/data-source-client/types.ts new file mode 100644 index 00000000000..923b0af6c2b --- /dev/null +++ b/packages/byods/src/data-source-client/types.ts @@ -0,0 +1,98 @@ +/** + * Represents the response from a data source. + * + * @public + */ +export interface DataSourceResponse { + /** + * The unique identifier for the data source response. + */ + id: string; + + /** + * The identifier for the schema associated with the data source. + */ + schemaId: string; + + /** + * The identifier for the organization associated with the data source. + */ + orgId: string; + + /** + * The plain client identifier (not a Hydra base64 id string). + */ + applicationId: string; + + /** + * The status of the data source response. Either "active" or "disabled". + */ + status: string; + + /** + * The JSON Web Signature token associated with the data source response. + */ + jwsToken: string; + + /** + * The identifier of the user who created the data source response. + */ + createdBy: string; + + /** + * The timestamp when the data source response was created. + */ + createdAt: string; + + /** + * The identifier of the user who last updated the data source response. + */ + updatedBy?: string; + + /** + * The timestamp when the data source response was last updated. + */ + updatedAt?: string; + + /** + * The error message associated with the data source response, if any. + */ + errorMessage?: string; +} + +/** + * Represents the request to a data source. + * + * @public + */ +export interface DataSourceRequest { + /** + * The identifier for the schema associated with the data source. + */ + schemaId: string; + + /** + * The URL of the data source. + */ + url: string; + + /** + * The audience for the data source request. + */ + audience: string; + + /** + * The subject of the data source request. + */ + subject: string; + + /** + * A unique nonce for the data source request. + */ + nonce: string; + + /** + * The lifetime of the token in minutes. + */ + tokenLifetimeMinutes: number; +} diff --git a/packages/byods/src/http-client/types.ts b/packages/byods/src/http-client/types.ts new file mode 100644 index 00000000000..f882efa77d8 --- /dev/null +++ b/packages/byods/src/http-client/types.ts @@ -0,0 +1,59 @@ +/** + * Represents a generic API response. + * + * @public + */ +export interface ApiResponse { + /** + * The response data. + */ + data: T; + + /** + * The response status code. + */ + status: number; +} + +/** + * Interface representing an HTTP client. + */ +export interface HttpClient { + /** + * Make a GET request to the specified endpoint. + * @param {string} endpoint - The endpoint to send the GET request to. + * @returns {Promise>} - A promise that resolves to the response data. + */ + get(endpoint: string): Promise>; + + /** + * Make a DELETE request to the specified endpoint. + * @param {string} endpoint - The endpoint to send the DELETE request to. + * @returns {Promise>} - A promise that resolves to the response data. + */ + delete(endpoint: string): Promise>; + + /** + * Make a POST request to the specified endpoint with the given body. + * @param {string} endpoint - The endpoint to send the POST request to. + * @param {Record} body - The body of the POST request. + * @returns {Promise>} - A promise that resolves to the response data. + */ + post(endpoint: string, body: Record): Promise>; + + /** + * Make a PUT request to the specified endpoint with the given body. + * @param {string} endpoint - The endpoint to send the PUT request to. + * @param {Record} body - The body of the PUT request. + * @returns {Promise>} - A promise that resolves to the response data. + */ + put(endpoint: string, body: Record): Promise>; + + /** + * Make a PATCH request to the specified endpoint with the given body. + * @param {string} endpoint - The endpoint to send the PATCH request to. + * @param {Record} body - The body of the PATCH request. + * @returns {Promise>} - A promise that resolves to the response data. + */ + patch(endpoint: string, body: Record): Promise>; +} diff --git a/packages/byods/src/index.ts b/packages/byods/src/index.ts index 158408b0be9..86847a6b329 100644 --- a/packages/byods/src/index.ts +++ b/packages/byods/src/index.ts @@ -1 +1,6 @@ -export {listDataSources, getDataSources} from './apiClient'; +import BYODS from './byods'; +import TokenManager from './token-manager'; +import BaseClient from './base-client'; +import DataSourceClient from './data-source-client'; + +export {BYODS, TokenManager, BaseClient, DataSourceClient}; diff --git a/packages/byods/src/token-manager/index.ts b/packages/byods/src/token-manager/index.ts new file mode 100644 index 00000000000..c88de4ecc7c --- /dev/null +++ b/packages/byods/src/token-manager/index.ts @@ -0,0 +1,186 @@ +import fetch, {Response} from 'node-fetch'; + +import {APPLICATION_ID_PREFIX, PRODUCTION_BASE_URL} from '../constants'; +import {TokenResponse, OrgServiceAppAuthorization, ServiceAppAuthorizationMap} from '../types'; + +/** + * The token manager for the BYoDS SDK. + */ +export default class TokenManager { + private serviceAppAuthorizations: ServiceAppAuthorizationMap = {}; + private clientId: string; + private clientSecret: string; + private serviceAppId: string; + private baseUrl: string; + + /** + * Creates an instance of TokenManager. + * + * @param clientId - The client ID of the service app. + * @param clientSecret - The client secret of the service app. + * @param baseUrl - The base URL for the API. Defaults to `PRODUCTION_BASE_URL`. + * @example + * const tokenManager = new TokenManager('your-client-id', 'your-client-secret'); + */ + constructor(clientId: string, clientSecret: string, baseUrl: string = PRODUCTION_BASE_URL) { + if (!clientId || !clientSecret) { + throw new Error('clientId and clientSecret are required'); + } + this.clientId = clientId; + this.clientSecret = clientSecret; + this.baseUrl = baseUrl; + this.serviceAppId = Buffer.from(`${APPLICATION_ID_PREFIX}${clientId}`).toString('base64'); + } + + /** + * Update the tokens and their expiration times. + * @param {TokenResponse} data - The token response data. + * @param {string} orgId - The organization ID. + * @returns {void} + * @example + * tokenManager.updateServiceAppToken(tokenResponse, 'org-id'); + */ + public updateServiceAppToken(data: TokenResponse, orgId: string): void { + this.serviceAppAuthorizations[orgId] = { + orgId, + serviceAppToken: { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + refreshAccessTokenExpiresAt: new Date(Date.now() + data.refresh_token_expires_in * 1000), + }, + }; + } + + /** + * Get the service app ID. + * @returns {string} + * @example + * const serviceAppId = tokenManager.getServiceAppId(); + */ + public getServiceAppId(): string { + return this.serviceAppId; + } + + /** + * Get the service app authorization data for a given organization ID. + * @param {string} orgId - The organization ID. + * @returns {Promise} + * @example + * const authorization = await tokenManager.getOrgServiceAppAuthorization('org-id'); + */ + public async getOrgServiceAppAuthorization(orgId: string): Promise { + if (!this.serviceAppAuthorizations[orgId]) { + return Promise.reject(new Error('Service app authorization not found')); + } + + return Promise.resolve(this.serviceAppAuthorizations[orgId]); + } + + /** + * Retrieve a new service app token using the service app owner's personal access token(PAT). + * @param {string} orgId - The organization ID. + * @param {string} personalAccessToken - The service app owner's personal access token or token from an integration that has the scope `spark:applications_token`. + * @returns {Promise} + * await tokenManager.getServiceAppTokenUsingPAT('org-id', 'personal-access-token'); + */ + public async getServiceAppTokenUsingPAT( + orgId: string, + personalAccessToken: string, + headers: Record = {} + ): Promise { + try { + const response: Response = await fetch( + `${this.baseUrl}/applications/${this.serviceAppId}/token`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${personalAccessToken}`, + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ + targetOrgId: orgId, + clientId: this.clientId, + clientSecret: this.clientSecret, + }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to retrieve token: ${response.statusText}`); + } + + const data: TokenResponse = (await response.json()) as TokenResponse; + this.updateServiceAppToken(data, orgId); + } catch (error) { + console.error('Error retrieving token after authorization:', error); + throw error; + } + } + + /** + * Refresh the access token using the refresh token. + * @param {string} orgId - The organization ID. + * @returns {Promise} + * await tokenManager.refreshServiceAppAccessToken('org-id'); + */ + public async refreshServiceAppAccessToken( + orgId: string, + headers: Record = {} + ): Promise { + if (!orgId) { + throw new Error('orgId not provided'); + } + + const serviceAppAuthorization = await this.getOrgServiceAppAuthorization(orgId); + const refreshToken = serviceAppAuthorization?.serviceAppToken.refreshToken; + + if (!refreshToken) { + throw new Error(`Refresh token was not found for org:${orgId}`); + } + + await this.saveServiceAppRegistrationData(orgId, refreshToken, headers); + } + + /** + * Save the service app registration using the provided refresh token. + * After saving, it can be fetched by using the {@link getOrgServiceAppAuthorization} method. + * @param {string} orgId - The organization ID. + * @param {string} refreshToken - The refresh token. + * @returns {Promise} + * @example + * await tokenManager.saveServiceAppRegistrationData('org-id', 'refresh-token'); + */ + public async saveServiceAppRegistrationData( + orgId: string, + refreshToken: string, + headers: Record = {} + ): Promise { + try { + const response: Response = await fetch(`${this.baseUrl}/access_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', // https://developer.webex.com/docs/login-with-webex#access-token-endpoint + ...headers, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to save service app registration: ${response.statusText}`); + } + + const data: TokenResponse = (await response.json()) as TokenResponse; + this.updateServiceAppToken(data, orgId); + } catch (error) { + console.error('Error saving service app registration:', error); + throw error; + } + } +} diff --git a/packages/byods/src/types.ts b/packages/byods/src/types.ts new file mode 100644 index 00000000000..d527727a931 --- /dev/null +++ b/packages/byods/src/types.ts @@ -0,0 +1,104 @@ +/** + * Configuration options for the SDK. + * + * @public + */ +export interface SDKConfig { + /** + * The client ID of the service app. + */ + clientId: string; + + /** + * The client secret of the service app. + */ + clientSecret: string; +} + +/** + * TokenResponse JSON shape from Webex APIs. + * + * @public + */ +export interface TokenResponse { + /** + * The access token. + */ + access_token: string; + + /** + * The expiration time of the access token in seconds. + */ + expires_in: number; + + /** + * The refresh token. + */ + refresh_token: string; + + /** + * The expiration time of the refresh token in seconds. + */ + refresh_token_expires_in: number; + + /** + * The type of the token. + */ + token_type: string; +} + +/** + * Represents a token with its expiration details. + * + * @public + */ +export interface ServiceAppToken { + /** + * The access token. + */ + accessToken: string; + + /** + * The refresh token. + */ + refreshToken: string; + + /** + * The expiration date of the access token. + */ + expiresAt: Date; + + /** + * The expiration date of the refresh token. + */ + refreshAccessTokenExpiresAt: Date; +} + +/** + * Represents a service app authorization token info for an organization. + * + * @public + */ +export interface OrgServiceAppAuthorization { + /** + * The organization ID. + */ + orgId: string; + + /** + * The token details. + */ + serviceAppToken: ServiceAppToken; +} + +/** + * Represents a map of service app authorizations to the orgId. + * + * @public + */ +export interface ServiceAppAuthorizationMap { + /** + * The organization ID mapped to its authorization details. + */ + [orgId: string]: OrgServiceAppAuthorization; +} diff --git a/packages/byods/test/unit/spec/base-client/index.ts b/packages/byods/test/unit/spec/base-client/index.ts new file mode 100644 index 00000000000..995cbe18e23 --- /dev/null +++ b/packages/byods/test/unit/spec/base-client/index.ts @@ -0,0 +1,70 @@ +import BaseClient from '../../../../src/base-client'; +import TokenManager from '../../../../src/token-manager'; +import DataSourceClient from '../../../../src/data-source-client'; +import {PRODUCTION_BASE_URL} from '../../../../src/constants'; + +describe('BaseClient Tests', () => { + const baseClient: BaseClient = new BaseClient( + PRODUCTION_BASE_URL, + {}, + new TokenManager('clientId', 'clientSecret'), + 'orgId' + ); + + it('creates an instance of BaseClient', () => { + expect(baseClient).toBeInstanceOf(BaseClient); + }); + + it('should make a GET request', async () => { + const mockResponse = {data: 'test', status: 200}; + jest.spyOn(baseClient, 'request').mockResolvedValue(mockResponse); + + const response = await baseClient.get('/test-endpoint'); + expect(response).toEqual(mockResponse); + }); + + it('should make a POST request', async () => { + const mockResponse = {data: 'test', status: 200}; + jest.spyOn(baseClient, 'request').mockResolvedValue(mockResponse); + + const response = await baseClient.post('/test-endpoint', {key: 'value'}); + expect(response).toEqual(mockResponse); + }); + + it('should make a PUT request', async () => { + const mockResponse = {data: 'test', status: 200}; + jest.spyOn(baseClient, 'request').mockResolvedValue(mockResponse); + + const response = await baseClient.put('/test-endpoint', {key: 'value'}); + expect(response).toEqual(mockResponse); + }); + + it('should make a PATCH request', async () => { + const mockResponse = {data: 'test', status: 200}; + jest.spyOn(baseClient, 'request').mockResolvedValue(mockResponse); + + const response = await baseClient.patch('/test-endpoint', {key: 'value'}); + expect(response).toEqual(mockResponse); + }); + + it('should make a DELETE request', async () => { + const mockResponse = {data: 'test', status: 200}; + jest.spyOn(baseClient, 'request').mockResolvedValue(mockResponse); + + const response = await baseClient.delete('/test-endpoint'); + expect(response).toEqual(mockResponse); + }); + + it('should get an HTTP client for org', () => { + const httpClient = baseClient.getHttpClientForOrg(); + expect(httpClient).toHaveProperty('get'); + expect(httpClient).toHaveProperty('post'); + expect(httpClient).toHaveProperty('put'); + expect(httpClient).toHaveProperty('patch'); + expect(httpClient).toHaveProperty('delete'); + }); + + it('should get a data source client', () => { + expect(baseClient.dataSource).toBeInstanceOf(DataSourceClient); + }); +}); diff --git a/packages/byods/test/unit/spec/byods/index.ts b/packages/byods/test/unit/spec/byods/index.ts new file mode 100644 index 00000000000..525e8d82c83 --- /dev/null +++ b/packages/byods/test/unit/spec/byods/index.ts @@ -0,0 +1,32 @@ +import BYODS from '../../../../src/byods'; +import TokenManager from '../../../../src/token-manager'; +import BaseClient from '../../../../src/base-client'; +import {SDKConfig} from '../../../../src/types'; +import DataSourceClient from '../../../../src/data-source-client'; + +jest.mock('node-fetch', () => jest.fn()); + +describe('BYODS Tests', () => { + const mockSDKConfig: SDKConfig = { + clientId: 'your-client-id', + clientSecret: 'your-client-secret', + }; + + const sdk = new BYODS(mockSDKConfig); + + it('should create an instance of BYODS', () => { + expect(sdk).toBeInstanceOf(BYODS); + }); + + it('should initialize TokenManager with correct parameters', () => { + expect(sdk.tokenManager).toBeInstanceOf(TokenManager); + }); + + it('should get a client for an organization', () => { + expect(sdk.getClientForOrg('myOrgId')).toBeInstanceOf(BaseClient); + }); + + it('should configure DataSourceClient with correct parameters', () => { + expect(sdk.getClientForOrg('myOrgId').dataSource).toBeInstanceOf(DataSourceClient); + }); +}); diff --git a/packages/byods/test/unit/spec/data-source-client/index.ts b/packages/byods/test/unit/spec/data-source-client/index.ts new file mode 100644 index 00000000000..5069c2f417a --- /dev/null +++ b/packages/byods/test/unit/spec/data-source-client/index.ts @@ -0,0 +1,204 @@ +import DataSourceClient from '../../../../src/data-source-client'; +import {DataSourceRequest, DataSourceResponse} from '../../../../src/data-source-client/types'; +import {HttpClient, ApiResponse} from '../../../../src/http-client/types'; + +describe('DataSourceClient', () => { + let httpClient: jest.Mocked; + let dataSourceClient: DataSourceClient; + + beforeEach(() => { + httpClient = { + post: jest.fn(), + get: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }; + dataSourceClient = new DataSourceClient(httpClient); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a new data source', async () => { + const request: DataSourceRequest = { + schemaId: 'myschemaid', + url: 'https://mydatasource.com', + audience: 'myaudience', + subject: 'mysubject', + nonce: 'uniqueNonce', + tokenLifetimeMinutes: 60, + }; + const response: ApiResponse = { + status: 201, + data: { + id: '123', + schemaId: 'myschemaid', + orgId: 'org123', + applicationId: 'app123', + status: 'active', + jwsToken: 'someJwsToken', + createdBy: 'someUser', + createdAt: '2024-01-01T00:00:00Z', + }, + }; + httpClient.post.mockResolvedValue(response); + + const result = await dataSourceClient.create(request); + + expect(httpClient.post).toHaveBeenCalledWith('/dataSources', request); + expect(result).toEqual(response); + }); + + it('should retrieve a data source by ID', async () => { + const id = '123'; + const response: ApiResponse = { + status: 200, + data: { + id: '123', + schemaId: 'myschemaid', + orgId: 'org123', + applicationId: 'app123', + status: 'active', + jwsToken: 'someJwsToken', + createdBy: 'someUser', + createdAt: '2024-01-01T00:00:00Z', + }, + }; + httpClient.get.mockResolvedValue(response); + + const result = await dataSourceClient.get(id); + + expect(httpClient.get).toHaveBeenCalledWith(`/dataSources/${id}`); + expect(result).toEqual(response); + }); + + it('should list all data sources', async () => { + const response: ApiResponse = { + data: [ + { + id: '123', + schemaId: 'myschemaid', + orgId: 'org123', + applicationId: 'app123', + status: 'active', + jwsToken: 'someJwsToken', + createdBy: 'someUser', + createdAt: '2024-01-01T00:00:00Z', + }, + ], + status: 200, + }; + httpClient.get.mockResolvedValue(response); + + const result = await dataSourceClient.list(); + + expect(httpClient.get).toHaveBeenCalledWith('/dataSources'); + expect(result).toEqual(response); + }); + + it('should update a data source by ID', async () => { + const id = '123'; + const request: DataSourceRequest = { + schemaId: 'updatedSchemaId', + url: 'https://updateddatasource.com', + audience: 'updatedAudience', + subject: 'updatedSubject', + nonce: 'updatedNonce', + tokenLifetimeMinutes: 60, + }; + const response: ApiResponse = { + status: 200, + data: { + id: '123', + schemaId: 'updatedSchemaId', + orgId: 'org123', + applicationId: 'app123', + status: 'active', + jwsToken: 'updatedJwsToken', + createdBy: 'someUser', + createdAt: '2024-01-01T00:00:00Z', + }, + }; + httpClient.put.mockResolvedValue(response); + + const result = await dataSourceClient.update(id, request); + + expect(httpClient.put).toHaveBeenCalledWith(`/dataSources/${id}`, request); + expect(result).toEqual(response); + }); + + it('should delete a data source by ID', async () => { + const id = '123'; + const response: ApiResponse = { + data: undefined, + status: 204, + }; + httpClient.delete.mockResolvedValue(response); + + const result = await dataSourceClient.delete(id); + + expect(httpClient.delete).toHaveBeenCalledWith(`/dataSources/${id}`); + expect(result).toEqual(response); + }); + + it('should handle errors when creating a data source', async () => { + const request: DataSourceRequest = { + schemaId: 'myschemaid', + url: 'https://mydatasource.com', + audience: 'myaudience', + subject: 'mysubject', + nonce: 'uniqueNonce', + tokenLifetimeMinutes: 60, + }; + const error = new Error('Network error'); + httpClient.post.mockRejectedValue(error); + + await expect(dataSourceClient.create(request)).rejects.toThrow('Network error'); + expect(httpClient.post).toHaveBeenCalledWith('/dataSources', request); + }); + + it('should handle errors when retrieving a data source by ID', async () => { + const id = '123'; + const error = new Error('Network error'); + httpClient.get.mockRejectedValue(error); + + await expect(dataSourceClient.get(id)).rejects.toThrow('Network error'); + expect(httpClient.get).toHaveBeenCalledWith(`/dataSources/${id}`); + }); + + it('should handle errors when listing all data sources', async () => { + const error = new Error('Network error'); + httpClient.get.mockRejectedValue(error); + + await expect(dataSourceClient.list()).rejects.toThrow('Network error'); + expect(httpClient.get).toHaveBeenCalledWith('/dataSources'); + }); + + it('should handle errors when updating a data source by ID', async () => { + const id = '123'; + const request: DataSourceRequest = { + schemaId: 'updatedSchemaId', + url: 'https://updateddatasource.com', + audience: 'updatedAudience', + subject: 'updatedSubject', + nonce: 'updatedNonce', + tokenLifetimeMinutes: 120, + }; + const error = new Error('Network error'); + httpClient.put.mockRejectedValue(error); + + await expect(dataSourceClient.update(id, request)).rejects.toThrow('Network error'); + expect(httpClient.put).toHaveBeenCalledWith(`/dataSources/${id}`, request); + }); + + it('should handle errors when deleting a data source by ID', async () => { + const id = '123'; + const error = new Error('Network error'); + httpClient.delete.mockRejectedValue(error); + + await expect(dataSourceClient.delete(id)).rejects.toThrowError('Network error'); + expect(httpClient.delete).toHaveBeenCalledWith(`/dataSources/${id}`); + }); +}); diff --git a/packages/byods/test/unit/spec/token-manager/index.ts b/packages/byods/test/unit/spec/token-manager/index.ts new file mode 100644 index 00000000000..0fd400a5b48 --- /dev/null +++ b/packages/byods/test/unit/spec/token-manager/index.ts @@ -0,0 +1,144 @@ +import fetch, {Response} from 'node-fetch'; + +import TokenManager from '../../../../src/token-manager'; +import {TokenResponse} from '../../../../src/types'; + +jest.mock('node-fetch', () => jest.fn()); + +describe('TokenManager', () => { + const clientId = 'test-client-id'; + const clientSecret = 'test-client-secret'; + const baseUrl = 'https://webexapis.com/v1'; + const orgId = 'test-org-id'; + const personalAccessToken = 'test-personal-access-token'; + const refreshToken = 'test-refresh-token'; + + let tokenManager: TokenManager; + + beforeEach(() => { + (fetch as jest.Mock).mockClear(); + tokenManager = new TokenManager(clientId, clientSecret, baseUrl); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should update service app token', async () => { + const tokenResponse: TokenResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + refresh_token_expires_in: 7200, + }; + + tokenManager.updateServiceAppToken(tokenResponse, orgId); + + const serviceAppAuthorization = await tokenManager.getOrgServiceAppAuthorization(orgId); + expect(serviceAppAuthorization).toBeDefined(); + expect(serviceAppAuthorization.serviceAppToken.accessToken).toBe('new-access-token'); + expect(serviceAppAuthorization.serviceAppToken.refreshToken).toBe('new-refresh-token'); + }); + + it('should get service app authorization', async () => { + const tokenResponse: TokenResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + refresh_token_expires_in: 7200, + }; + + tokenManager.updateServiceAppToken(tokenResponse, orgId); + + const serviceAppAuthorization = await tokenManager.getOrgServiceAppAuthorization(orgId); + expect(serviceAppAuthorization).toBeDefined(); + expect(serviceAppAuthorization.serviceAppToken.accessToken).toBe('new-access-token'); + }); + + it('should throw error if service app authorization not found', async () => { + await expect(tokenManager.getOrgServiceAppAuthorization(orgId)).rejects.toThrow( + 'Service app authorization not found' + ); + }); + + it('should refresh service app access token', async () => { + const tokenResponse: TokenResponse = { + access_token: 'new-access-token', + refresh_token: refreshToken, + expires_in: 3600, + token_type: 'Bearer', + refresh_token_expires_in: 7200, + }; + + tokenManager.updateServiceAppToken(tokenResponse, orgId); + + const mockResponse = { + json: jest.fn().mockResolvedValue(tokenResponse), + ok: true, + } as unknown as Response; + + (fetch as unknown as jest.MockedFunction).mockResolvedValue(mockResponse); + + await tokenManager.refreshServiceAppAccessToken(orgId); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/access_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + }), + }); + const serviceAppAuthorization = await tokenManager.getOrgServiceAppAuthorization(orgId); + expect(serviceAppAuthorization.serviceAppToken.accessToken).toBe('new-access-token'); + }); + + it('should throw error if refresh token is undefined', async () => { + await expect(tokenManager.refreshServiceAppAccessToken(orgId)).rejects.toThrow( + 'Service app authorization not found' + ); + }); + + it('should retrieve token after authorization', async () => { + const tokenResponse: TokenResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token_expires_in: 7200, + }; + + const mockResponse = { + json: jest.fn().mockResolvedValue(tokenResponse), + ok: true, + } as unknown as Response; + + (fetch as unknown as jest.MockedFunction).mockResolvedValue(mockResponse); + + await tokenManager.getServiceAppTokenUsingPAT(orgId, personalAccessToken); + + expect(fetch).toHaveBeenCalledWith( + `${baseUrl}/applications/${tokenManager.getServiceAppId()}/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${personalAccessToken}`, + }, + body: JSON.stringify({ + targetOrgId: orgId, + clientId, + clientSecret, + }), + } + ); + const serviceAppAuthorization = await tokenManager.getOrgServiceAppAuthorization(orgId); + expect(serviceAppAuthorization.serviceAppToken.accessToken).toBe('new-access-token'); + }); +}); diff --git a/packages/byods/tsconfig.json b/packages/byods/tsconfig.json index 95a8783c6e1..7dbec17ff8a 100644 --- a/packages/byods/tsconfig.json +++ b/packages/byods/tsconfig.json @@ -106,7 +106,7 @@ }, "typedocOptions": { "entryPoints": [ - "./src/index.ts" + "./src/**" ], "sort": [ "source-order" @@ -120,7 +120,8 @@ }, "include": [ "src/**/*.ts", - "jest.global.d.ts" + "jest.global.d.ts", + "test/unit/spec/**/*.ts", ], "exclude": [ "./node_modules/**", diff --git a/yarn.lock b/yarn.lock index 58596af11ff..2527d5aca6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5861,7 +5861,7 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:^2.6.11": +"@types/node-fetch@npm:2.6.11": version: 2.6.11 resolution: "@types/node-fetch@npm:2.6.11" dependencies: @@ -7424,14 +7424,13 @@ __metadata: "@types/jest": 27.4.1 "@types/mocha": 9.0.0 "@types/node": 16.11.9 - "@types/node-fetch": ^2.6.11 + "@types/node-fetch": 2.6.11 "@types/uuid": 8.3.4 "@typescript-eslint/eslint-plugin": 5.38.1 "@typescript-eslint/parser": 5.38.1 "@web/dev-server": 0.4.5 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" - "@webex/media-helpers": "workspace:*" chai: 4.3.4 cspell: 5.19.2 esbuild: ^0.17.19 @@ -7445,6 +7444,7 @@ __metadata: eslint-plugin-tsdoc: 0.2.14 jest: 27.5.1 jest-junit: 13.0.0 + jose: 5.8.0 karma: 6.4.3 karma-chai: 0.1.0 karma-chrome-launcher: 3.1.0 @@ -7458,7 +7458,7 @@ __metadata: karma-typescript: 5.5.3 karma-typescript-es6-transform: 5.5.3 mocha: 10.6.0 - node-fetch: ^3.3.2 + node-fetch: 3.3.2 prettier: 2.5.1 puppeteer: 22.13.0 rimraf: 3.0.2 @@ -21690,6 +21690,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:5.8.0": + version: 5.8.0 + resolution: "jose@npm:5.8.0" + checksum: bb9cd97ac6ccb8148a8e23d6a7f61e5756a3373a7d65dd783051d8af409c3534bdc2a2c30ecd1820988ea943aba5755b2a45b86955c5765d71691bb0ddd45d61 + languageName: node + linkType: hard + "js-logger@npm:^1.6.1": version: 1.6.1 resolution: "js-logger@npm:1.6.1" @@ -25195,6 +25202,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:3.3.2, node-fetch@npm:^3.3.2": + version: 3.3.2 + resolution: "node-fetch@npm:3.3.2" + dependencies: + data-uri-to-buffer: ^4.0.0 + fetch-blob: ^3.1.4 + formdata-polyfill: ^4.0.10 + checksum: 06a04095a2ddf05b0830a0d5302699704d59bda3102894ea64c7b9d4c865ecdff2d90fd042df7f5bc40337266961cb6183dcc808ea4f3000d024f422b462da92 + languageName: node + linkType: hard + "node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -25209,17 +25227,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^3.3.2": - version: 3.3.2 - resolution: "node-fetch@npm:3.3.2" - dependencies: - data-uri-to-buffer: ^4.0.0 - fetch-blob: ^3.1.4 - formdata-polyfill: ^4.0.10 - checksum: 06a04095a2ddf05b0830a0d5302699704d59bda3102894ea64c7b9d4c865ecdff2d90fd042df7f5bc40337266961cb6183dcc808ea4f3000d024f422b462da92 - languageName: node - linkType: hard - "node-forge@npm:^1, node-forge@npm:^1.2.1": version: 1.3.1 resolution: "node-forge@npm:1.3.1"