diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 5fb32ca01f23c..2a9849421c696 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog +## [1.0.6] - 2024-11-20 + +### Changed + +- Use client Connect tokens to make api calls directly from the client. +- Deprecated the `environments` property on `createFrontendClient` since it is now + stored in the token + ## [1.0.5] - 2024-11-18 ### Changed diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index c46ed631dd510..0729a3831fb99 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -9,13 +9,18 @@ "version": "1.0.5", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "simple-oauth2": "^5.1.0" + "@rails/actioncable": "^8.0.0", + "commander": "^12.1.0", + "simple-oauth2": "^5.1.0", + "ws": "^8.18.0" }, "devDependencies": { "@types/fetch-mock": "^7.3.8", "@types/jest": "^29.5.13", - "@types/node": "^20.14.9", + "@types/node": "^20.17.6", + "@types/rails__actioncable": "^6.1.11", "@types/simple-oauth2": "^5.0.7", + "@types/ws": "^8.5.13", "jest": "^29.7.0", "jest-fetch-mock": "^3.0.3", "nodemon": "^3.1.7", @@ -982,6 +987,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@rails/actioncable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.0.0.tgz", + "integrity": "sha512-9IXyJeaBggOzlD3pF4/yWELdyUWZm/KTyKBRqxNf9laLBXPqxJt3t6fO+X4s0WajMR8cIhzkxvq1gxsXVbn3LA==", + "license": "MIT" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -1120,14 +1131,22 @@ } }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "20.17.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", + "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, + "node_modules/@types/rails__actioncable": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@types/rails__actioncable/-/rails__actioncable-6.1.11.tgz", + "integrity": "sha512-L6A3Rg6sGsv2cqalOgdOmyFvL1Pw69Mg0WuG6NtY9chzabhtkiSFY5fczo72mqRGezrMvl0Jy80v+N719CW+Tg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/simple-oauth2": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-5.0.7.tgz", @@ -1140,6 +1159,16 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -1589,6 +1618,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3892,10 +3930,11 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.1.0", @@ -4017,6 +4056,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e0c33dd299616..4894158faec45 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,17 +1,27 @@ { "name": "@pipedream/sdk", - "version": "1.0.5", + "version": "1.0.6", "description": "Pipedream SDK", - "main": "dist/server/index.js", - "module": "dist/server/index.js", - "types": "dist/server/index.d.ts", - "browser": "./dist/browser/index.js", + "main": "dist/server/server/index.js", + "module": "dist/server/server/index.js", + "types": "dist/server/server/index.d.ts", + "browser": "./dist/browser/browser/index.js", "exports": { ".": { - "browser": "./dist/browser/index.js", - "import": "./dist/server/index.js", - "require": "./dist/server/index.js", - "default": "./dist/server/index.js" + "browser": "./dist/browser/browser/index.js", + "import": "./dist/server/server/index.js", + "require": "./dist/server/server/index.js", + "default": "./dist/server/server/index.js" + }, + "./server": { + "import": "./dist/server/server/index.js", + "require": "./dist/server/server/index.js", + "types": "./dist/server/server/index.d.ts" + }, + "./browser": { + "import": "./dist/browser/browser/index.js", + "require": "./dist/browser/browser/index.js", + "types": "./dist/browser/browser/index.d.ts" } }, "engines": { @@ -25,12 +35,14 @@ "access": "public" }, "scripts": { + "lint": "eslint --fix --ext .ts src", "prepublish": "rm -rf dist && npm run build", "build": "npm run build:node && npm run build:browser", "build:node": "tsc -p tsconfig.node.json", "build:browser": "tsc -p tsconfig.browser.json", "test": "jest", - "watch": "nodemon --watch src --ext ts --exec 'npm run build'" + "watch": "nodemon --watch src --ext ts --exec 'npm run build'", + "cli": "node dist/server/server/cli.js" }, "files": [ "dist" @@ -38,8 +50,10 @@ "devDependencies": { "@types/fetch-mock": "^7.3.8", "@types/jest": "^29.5.13", - "@types/node": "^20.14.9", + "@types/node": "^20.17.6", + "@types/rails__actioncable": "^6.1.11", "@types/simple-oauth2": "^5.0.7", + "@types/ws": "^8.5.13", "jest": "^29.7.0", "jest-fetch-mock": "^3.0.3", "nodemon": "^3.1.7", @@ -47,6 +61,9 @@ "typescript": "^5.5.2" }, "dependencies": { - "simple-oauth2": "^5.1.0" + "@rails/actioncable": "^8.0.0", + "commander": "^12.1.0", + "simple-oauth2": "^5.1.0", + "ws": "^8.18.0" } } diff --git a/packages/sdk/src/browser/async.ts b/packages/sdk/src/browser/async.ts new file mode 100644 index 0000000000000..a174d6aff5a31 --- /dev/null +++ b/packages/sdk/src/browser/async.ts @@ -0,0 +1,24 @@ +import { AsyncResponseManager } from "../shared/async"; +import type { AsyncResponseManagerOpts } from "../shared/async"; + +export type BrowserAsyncResponseManagerOpts = { + apiHost: string; + getConnectToken: () => Promise; +}; + +export class BrowserAsyncResponseManager extends AsyncResponseManager { + private browserOpts: BrowserAsyncResponseManagerOpts; + + constructor(opts: BrowserAsyncResponseManagerOpts) { + super(); + this.browserOpts = opts; + } + + protected override async getOpts(): Promise { + const token = await this.browserOpts.getConnectToken(); + const url = `wss://${this.browserOpts.apiHost}/websocket?ctok=${token}`; + return { + url, + }; + } +} diff --git a/packages/sdk/src/browser/index.ts b/packages/sdk/src/browser/index.ts index 74d3507770c7b..ac5c7d5617190 100644 --- a/packages/sdk/src/browser/index.ts +++ b/packages/sdk/src/browser/index.ts @@ -4,14 +4,22 @@ // operations, like connecting accounts via Pipedream Connect. See the server/ // directory for the server client. +import { BrowserAsyncResponseManager } from "./async"; +import { + AccountsRequestResponse, + BaseClient, + GetAccountOpts, + type ConnectTokenResponse, +} from "../shared"; +export type * from "../shared"; + /** * Options for creating a browser-side client. This is used to configure the * BrowserClient instance. */ type CreateBrowserClientOpts = { /** - * The environment in which the browser client is running (e.g., "production", - * "development"). + * @deprecated environment is set on the server when generating the client token */ environment?: string; @@ -20,8 +28,31 @@ type CreateBrowserClientOpts = { * "pipedream.com" if not provided. */ frontendHost?: string; + + /** + * The API host URL. Used by Pipedream employees. Defaults to + * "api.pipedream.com" if not provided. + */ + apiHost?: string; + + /** + * Will be called whenever we need a new token. + * + * The callback function should return the response from + * `serverClient.createConnectToken`. + */ + tokenCallback?: TokenCallback; + + /** + * An external user ID associated with the token. + */ + externalUserId?: string; }; +export type TokenCallback = (opts: { + externalUserId: string; +}) => Promise; + /** * The name slug for an app, a unique, human-readable identifier like "github" * or "google_sheets". Find this in the Authentication section for any app's @@ -51,8 +82,10 @@ class ConnectError extends Error {} type StartConnectOpts = { /** * The token used for authenticating the connection. + * + * Optional if client already initialized with token */ - token: string; + token?: string; /** * The app to connect to, either as an ID or an object containing the ID. @@ -84,9 +117,10 @@ type StartConnectOpts = { * * @example * ```typescript - * const client = createFrontendClient({ - * environment: "production", - * }); + const client = createFrontendClient({ + tokenCallback, + externalUserId, + }); * ``` * @param opts - The options for creating the browser client. * @returns A new instance of `BrowserClient`. @@ -98,12 +132,17 @@ export function createFrontendClient(opts: CreateBrowserClientOpts = {}) { /** * A client for interacting with the Pipedream Connect API from the browser. */ -class BrowserClient { - private environment?: string; +export class BrowserClient extends BaseClient { + protected override asyncResponseManager: BrowserAsyncResponseManager; private baseURL: string; private iframeURL: string; private iframe?: HTMLIFrameElement; private iframeId = 0; + private tokenCallback?: TokenCallback; + private _token?: string; + private _tokenExpiresAt?: Date; + private _tokenRequest?: Promise; + externalUserId?: string; /** * Constructs a new `BrowserClient` instance. @@ -111,9 +150,58 @@ class BrowserClient { * @param opts - The options for configuring the browser client. */ constructor(opts: CreateBrowserClientOpts) { - this.environment = opts.environment; + super(opts); this.baseURL = `https://${opts.frontendHost || "pipedream.com"}`; this.iframeURL = `${this.baseURL}/_static/connect.html`; + this.tokenCallback = opts.tokenCallback; + this.externalUserId = opts.externalUserId; + this.asyncResponseManager = new BrowserAsyncResponseManager({ + apiHost: this.apiHost, + getConnectToken: () => this.token(), + }); + } + + private async token() { + if ( + this._token && + this._tokenExpiresAt && + this._tokenExpiresAt > new Date() + ) { + return this._token; + } + + if (this._tokenRequest) { + return this._tokenRequest; + } + + const tokenCallback = this.tokenCallback; + const externalUserId = this.externalUserId; + + if (!tokenCallback) { + throw new Error("No token callback provided"); + } + if (!externalUserId) { + throw new Error("No external user ID provided"); + } + + // Ensure only one token request is in-flight at a time. + this._tokenRequest = (async () => { + const { + token, expires_at, + } = await tokenCallback({ + externalUserId: externalUserId, + }); + this._token = token; + this._tokenExpiresAt = new Date(expires_at); + this._tokenRequest = undefined; + return token; + })(); + + return this._tokenRequest; + } + + private refreshToken() { + this._token = undefined; } /** @@ -135,7 +223,7 @@ class BrowserClient { * }); * ``` */ - public connectAccount(opts: StartConnectOpts) { + public async connectAccount(opts: StartConnectOpts) { const onMessage = (e: MessageEvent) => { switch (e.data?.type) { case "success": @@ -157,10 +245,11 @@ class BrowserClient { window.addEventListener("message", onMessage); try { - this.createIframe(opts); + await this.createIframe(opts); } catch (err) { opts.onError?.(err as ConnectError); } + this.refreshToken(); // token expires once it's used to create a connected account. We need to get a new token for the next requests. } /** @@ -182,15 +271,12 @@ class BrowserClient { * * @throws {ConnectError} If the app option is not a string. */ - private createIframe(opts: StartConnectOpts) { + private async createIframe(opts: StartConnectOpts) { + const token = opts.token || (await this.token()); const qp = new URLSearchParams({ - token: opts.token, + token, }); - if (this.environment) { - qp.set("environment", this.environment); - } - if (typeof opts.app === "string") { qp.set("app", opts.app); } else { @@ -216,4 +302,17 @@ class BrowserClient { document.body.appendChild(iframe); } + + protected async authHeaders(): Promise { + if (!(await this.token())) { + throw new Error("No token provided"); + } + return `Bearer ${await this.token()}`; + } + + public getAccounts( + params?: Omit, + ): Promise { + return super.getAccounts(params); + } } diff --git a/packages/sdk/src/server/__tests__/server.test.ts b/packages/sdk/src/server/__tests__/server.test.ts index 0356686a3c3af..1bab68640d959 100644 --- a/packages/sdk/src/server/__tests__/server.test.ts +++ b/packages/sdk/src/server/__tests__/server.test.ts @@ -249,6 +249,7 @@ describe("BackendClient", () => { headers: expect.objectContaining({ "Authorization": expect.any(String), "Content-Type": "application/json", + "X-PD-Environment": "production", }), }), ); @@ -298,13 +299,15 @@ describe("BackendClient", () => { describe("getAccounts", () => { it("should retrieve accounts", async () => { - fetchMock.mockResponseOnce( - JSON.stringify([ - { - id: "account-1", - name: "Test Account", - }, - ]), + fetchMock.mockResponse( + JSON.stringify({ + data: [ + { + id: "account-1", + name: "Test Account", + }, + ], + }), { headers: { "Content-Type": "application/json", @@ -316,7 +319,7 @@ describe("BackendClient", () => { include_credentials: true, }); - expect(result).toEqual([ + expect(result.data).toEqual([ { id: "account-1", name: "Test Account", diff --git a/packages/sdk/src/server/async.ts b/packages/sdk/src/server/async.ts new file mode 100644 index 0000000000000..de28ac31eeabb --- /dev/null +++ b/packages/sdk/src/server/async.ts @@ -0,0 +1,33 @@ +import type { AccessToken } from "simple-oauth2"; +import { AsyncResponseManager } from "../shared/async"; +import type { AsyncResponseManagerOpts } from "../shared/async"; +import { adapters } from "@rails/actioncable"; +import * as WS from "ws"; + +export type ServerAsyncResponseManagerOpts = { + apiHost: string; + getOauthToken: () => Promise | AccessToken; + getProjectId: () => Promise | string; +}; + +export class ServerAsyncResponseManager extends AsyncResponseManager { + private serverOpts: ServerAsyncResponseManagerOpts; + + constructor(opts: ServerAsyncResponseManagerOpts) { + super(); + this.serverOpts = opts; + if (typeof adapters.WebSocket === "undefined") + adapters.WebSocket == WS; + } + + protected override async getOpts(): Promise { + const token = await this.serverOpts.getOauthToken(); + const projectId = await this.serverOpts.getProjectId(); + return { + url: `wss://${this.serverOpts.apiHost}/websocket?oauth_token=${token}`, + subscriptionParams: { + project_id: projectId, + }, + }; + } +} diff --git a/packages/sdk/src/server/cli.ts b/packages/sdk/src/server/cli.ts new file mode 100644 index 0000000000000..eb6d3c61d47f9 --- /dev/null +++ b/packages/sdk/src/server/cli.ts @@ -0,0 +1,266 @@ +import { createBackendClient } from "./index"; +import { program } from "commander"; + +const { + CLIENT_ID, CLIENT_SECRET, PROJECT_ID, API_HOST, +} = process.env; + +if (!CLIENT_ID || !CLIENT_SECRET || !PROJECT_ID) { + console.error("Error: Missing required environment variables (CLIENT_ID, CLIENT_SECRET, PROJECT_ID)."); + process.exit(1); +} + +const client = createBackendClient({ + credentials: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }, + projectId: PROJECT_ID, + apiHost: API_HOST, +}); + +program + .name("connect-cli") + .description("CLI for interacting with the Pipedream Connect API") + .version("1.0.0"); + +const handleError = (error: unknown, message: string) => { + if (error instanceof Error) { + console.error(`${message}:`, error.message); + } else { + console.error(`${message}:`, String(error)); + } +}; + +program + .command("list-project-info") + .description("List information about the project, including linked apps.") + .action(async () => { + try { + const projectInfo = await client.getProjectInfo(); + console.log(JSON.stringify(projectInfo, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch project info"); + } + }); + +program + .command("delete-account ") + .description("Delete an account by its ID.") + .action(async (accountId) => { + try { + await client.deleteAccount(accountId); + console.log(`Account with ID ${accountId} has been deleted.`); + } catch (error) { + handleError(error, "Failed to delete account"); + } + }); + +program + .command("delete-accounts-by-app ") + .description("Delete all accounts associated with a specific app.") + .action(async (appId) => { + try { + await client.deleteAccountsByApp(appId); + console.log(`All accounts associated with app ID ${appId} have been deleted.`); + } catch (error) { + handleError(error, "Failed to delete accounts by app"); + } + }); + +program + .command("delete-external-user ") + .description("Delete all accounts associated with a specific external ID.") + .action(async (externalId) => { + try { + await client.deleteExternalUser(externalId); + console.log(`All accounts associated with external ID ${externalId} have been deleted.`); + } catch (error) { + handleError(error, "Failed to delete external user"); + } + }); + +program + .command("create-connect-token ") + .description("Create a new Pipedream Connect token.") + .option("--success-redirect-uri ", "URL to redirect the user to upon successful connection") + .option("--error-redirect-uri ", "URL to redirect the user to upon failed connection") + .option("--webhook-uri ", "Webhook URI that Pipedream can invoke on success or failure of connection requests") + .option("--allowed-origins ", "Comma-separated list of allowed origins") + .action(async (externalUserId, options) => { + try { + const tokenResponse = await client.createConnectToken({ + external_user_id: externalUserId, + success_redirect_uri: options.successRedirectUri, + error_redirect_uri: options.errorRedirectUri, + webhook_uri: options.webhookUri, + allowed_origins: options.allowedOrigins + ? options.allowedOrigins.split(",") + : undefined, + }); + console.log(JSON.stringify(tokenResponse, null, 2)); + } catch (error) { + handleError(error, "Failed to create connect token"); + } + }); + +program + .command("get-accounts") + .description("Retrieve the list of accounts associated with the project.") + .option("--include-credentials ", "Include credentials in the response") + .action(async (options) => { + try { + const params = options.includeCredentials + ? { + include_credentials: options.includeCredentials, + } + : {}; + const accounts = await client.getAccounts(params); + console.log(JSON.stringify(accounts, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch accounts"); + } + }); + +program + .command("get-account-by-id ") + .description("Retrieve a specific account by ID.") + .action(async (accountId) => { + try { + const account = await client.getAccountById(accountId); + console.log(JSON.stringify(account, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch account by ID"); + } + }); + +program + .command("list-apps") + .description("Retrieve the list of apps.") + .option("--query ", "Query string to filter apps") + .action(async (options) => { + try { + const apps = await client.apps({ + q: options.query, + }); + console.log(JSON.stringify(apps, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch apps"); + } + }); + +program + .command("get-app ") + .description("Retrieve a specific app by ID or name slug.") + .action(async (idOrNameSlug) => { + try { + const app = await client.app(idOrNameSlug); + console.log(JSON.stringify(app, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch app"); + } + }); + +program + .command("list-components") + .description("Retrieve the list of components.") + .option("--app ", "Filter components by app") + .option("--query ", "Query string to filter components") + .option("--component-type ", "Filter components by type (trigger or action)") + .action(async (options) => { + try { + const components = await client.components({ + app: options.app, + q: options.query, + componentType: options.componentType, + }); + console.log(JSON.stringify(components, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch components"); + } + }); + +program + .command("get-component ") + .description("Retrieve a specific component by key.") + .action(async (key) => { + try { + const component = await client.component({ + key, + }); + console.log(JSON.stringify(component, null, 2)); + } catch (error) { + handleError(error, "Failed to fetch component"); + } + }); + +program + .command("configure-component") + .description("Configure a component.") + .requiredOption("--user-id ", "User ID") + .requiredOption("--component-id ", "Component ID") + .requiredOption("--prop-name ", "Property name") + .requiredOption("--configured-props ", "Configured properties as JSON string") + .option("--dynamic-props-id ", "Dynamic properties ID") + .action(async (options) => { + try { + const configuredProps = JSON.parse(options.configuredProps); + const response = await client.componentConfigure({ + userId: options.userId, + componentId: options.componentId, + propName: options.propName, + configuredProps, + dynamicPropsId: options.dynamicPropsId, + }); + console.log(JSON.stringify(response, null, 2)); + } catch (error) { + handleError(error, "Failed to configure component"); + } + }); + +program + .command("reload-component-props") + .description("Reload component properties.") + .requiredOption("--user-id ", "User ID") + .requiredOption("--component-id ", "Component ID") + .requiredOption("--configured-props ", "Configured properties as JSON string") + .option("--dynamic-props-id ", "Dynamic properties ID") + .action(async (options) => { + try { + const configuredProps = JSON.parse(options.configuredProps); + const response = await client.componentReloadProps({ + userId: options.userId, + componentId: options.componentId, + configuredProps, + dynamicPropsId: options.dynamicPropsId, + }); + console.log(JSON.stringify(response, null, 2)); + } catch (error) { + handleError(error, "Failed to reload component properties"); + } + }); + +program + .command("run-action") + .description("Run an action.") + .requiredOption("--user-id ", "User ID") + .requiredOption("--action-id ", "Action ID") + .requiredOption("--configured-props ", "Configured properties as JSON string") + .option("--dynamic-props-id ", "Dynamic properties ID") + .action(async (options) => { + try { + const configuredProps = JSON.parse(options.configuredProps); + const response = await client.actionRun({ + userId: options.userId, + actionId: options.actionId, + configuredProps, + dynamicPropsId: options.dynamicPropsId, + }); + console.log(JSON.stringify(response, null, 2)); + } catch (error) { + handleError(error, "Failed to run action"); + } + }); + +// Parse and execute commands +program.parse(process.argv); diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 10590b5424632..aa135befc4849 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -3,9 +3,13 @@ // See the browser/ directory for the browser client. import { - AccessToken, - ClientCredentials, + AccessToken, ClientCredentials, } from "simple-oauth2"; +import { + Account, BaseClient, type AppInfo, type ConnectTokenResponse, +} from "../shared"; +import { ServerAsyncResponseManager } from "./async"; +export * from "../shared"; /** * OAuth credentials for your Pipedream account, containing client ID and @@ -23,7 +27,7 @@ export type ProjectEnvironment = "development" | "production"; /** * Options for creating a server-side client. - * This is used to configure the BackendClient instance. + * This is used to configure the ServerClient instance. */ export type BackendClientOpts = { /** @@ -55,21 +59,12 @@ export type BackendClientOpts = { workflowDomain?: string; }; -/** - * Different ways in which customers can authorize requests to HTTP endpoints - */ -export enum HTTPAuthType { - None = "none", - StaticBearer = "static_bearer_token", - OAuth = "oauth" -} - /** * Options for creating a Connect token. */ -export type ConnectTokenOpts = { +export type ConnectTokenCreateOpts = { /** - * An external user ID associated with the token. + * The ID of the user in your system. */ external_user_id: string; @@ -90,26 +85,9 @@ export type ConnectTokenOpts = { webhook_uri?: string; /** - * Specify the environment ("production" or "development") to use for the - * account connection flow. Defaults to "production". - * - * @deprecated in favor of the `environment` field in `BackendClientOpts`. - * This field is completely ignored. - */ - environment_name?: string; -}; - -export type AppInfo = { - /** - * ID of the app. Only applies for OAuth apps. - */ - id?: string; - - /** - * The name slug of the target app (see - * https://pipedream.com/docs/connect/quickstart#find-your-apps-name-slug) + * Specify which origins can use the token to call the Pipedream API. */ - name_slug: string; + allowed_origins?: string[]; }; /** @@ -122,93 +100,6 @@ export type ProjectInfoResponse = { apps: AppInfo[]; }; -/** - * Response received after creating a connect token. - */ -export type ConnectTokenResponse = { - /** - * The generated token. - */ - token: string; - - /** - * The expiration time of the token in ISO 8601 format. - */ - expires_at: string; - - /** - * The Connect Link URL - */ - connect_link_url: string; -}; - -/** - * The types of authentication that Pipedream apps support. - */ -export enum AppAuthType { - OAuth = "oauth", - Keys = "keys", - None = "none", -} - -/** - * Response object for a Pipedream app's metadata - */ -export type AppResponse = AppInfo & { - /** - * The human-readable name of the app. - */ - name: string; - - /** - * The authentication type used by the app. - */ - auth_type: AppAuthType; - - /** - * The URL to the app's logo. - */ - img_src: string; - - /** - * A JSON string representing the custom fields for the app. - */ - custom_fields_json: string; - - /** - * Categories associated with the app. - */ - categories: string[]; -}; - -/** - * Parameters for the retrieval of accounts from the Connect API - */ -export type GetAccountOpts = { - /** - * The ID or name slug of the app, in case you want to only retrieve the - * accounts for a specific app. - */ - app?: string; - - /** - * The ID of the app (if it's an OAuth app), in case you want to only retrieve - * the accounts for a specific app. - */ - oauth_app_id?: string; - - /** - * The external user ID associated with the account, in case you want to only - * retrieve the accounts of a specific user. - */ - external_user_id?: string; - - /** - * Whether to retrieve the account's credentials or not. - */ - include_credentials?: boolean; -}; - /** * Parameters for the retrieval of an account from the Connect API */ @@ -219,123 +110,24 @@ export type GetAccountByIdOpts = { include_credentials?: boolean; }; -/** - * End user account data, returned from the API. - */ -export type Account = { - /** - * The unique ID of the account. - */ - id: string; - - /** - * The name of the account. - */ - name: string; - - /** - * The external ID associated with the account. - */ - external_id: string; - - /** - * Indicates if the account is healthy. Pipedream will periodically retry - * token refresh and test requests for unhealthy accounts. - */ - healthy: boolean; - - /** - * Indicates if the account is no longer active. - */ - dead: boolean; - - /** - * The app associated with the account. - */ - app: AppResponse; - - /** - * The date and time the account was created, an ISO 8601 formatted string. - */ - created_at: string; - - /** - * The date and time the account was last updated, an ISO 8601 formatted - * string. - */ - updated_at: string; - - /** - * The credentials associated with the account, if the `include_credentials` - * parameter was set to true in the request. - */ - credentials?: Record; -}; - -/** - * Error response returned by the API in case of an error. - */ -export type ErrorResponse = { - /** - * The error message returned by the API. - */ - error: string; -}; - -/** - * A generic API response that can either be a success or an error. - */ -export type ConnectAPIResponse = T | ErrorResponse; - -/** - * Options for making a request to the Pipedream API. - */ -interface RequestOptions extends Omit { - /** - * Query parameters to include in the request URL. - */ - params?: Record; - - /** - * Headers to include in the request. - */ - headers?: Record; - - /** - * The URL to make the request to. - */ - baseURL?: string; - - /** - * The body of the request. - */ - body?: Record | string | FormData | URLSearchParams | null; - - /** - * A flag to indicate that you want to get the full response object, not just - * the body. Note that when this flag is set, responses with unsuccessful HTTP - * statuses won't throw exceptions. Instead, you'll need to check the status - * code in the response object. Defaults to false. - */ - fullResponse?: boolean; -} - /** * Creates a new instance of BackendClient with the provided options. * * @example * * ```typescript - * const client = createBackendClient({ - * credentials: { - * clientId: "your-client-id", - * clientSecret: "your-client-secret", - * }, - * }); + const serverClient = createBackendClient({ + environment: "development", + projectId: "", + credentials: { + clientId: "", + clientSecret: "", + }, + }) * ``` * * @param opts - The options for creating the server client. - * @returns A new instance of BackendClient. + * @returns A new instance of ServerClient. */ export function createBackendClient(opts: BackendClientOpts) { return new BackendClient(opts); @@ -344,36 +136,37 @@ export function createBackendClient(opts: BackendClientOpts) { /** * A client for interacting with the Pipedream Connect API on the server-side. */ -export class BackendClient { - private environment: string; +export class BackendClient extends BaseClient { + protected override asyncResponseManager: ServerAsyncResponseManager; private oauthClient: ClientCredentials; private oauthToken?: AccessToken; - private projectId: string; - private readonly baseApiUrl: string; - private readonly workflowDomain: string; + protected projectId: string; /** - * Constructs a new BackendClient instance. + * Constructs a new ServerClient instance. * * @param opts - The options for configuring the server client. + * @param oauthClient - An optional OAuth client to use for authentication in tests */ constructor(opts: BackendClientOpts) { - this.ensureValidEnvironment(opts.environment); - this.environment = opts.environment!; + super(opts); + this.ensureValidEnvironment(opts.environment); this.projectId = opts.projectId; - if (!this.projectId) { - throw new Error("Project ID is required"); - } - - const { - apiHost = "api.pipedream.com", - workflowDomain = "m.pipedream.net", - } = opts; - this.baseApiUrl = `https://${apiHost}/v1`; - this.workflowDomain = workflowDomain; this.oauthClient = this.newOauthClient(opts.credentials, this.baseApiUrl); + this.asyncResponseManager = new ServerAsyncResponseManager({ + apiHost: this.apiHost, + getOauthToken: async () => { + await this.ensureValidOauthToken(); + return this.oauthToken as AccessToken; + }, + getProjectId: () => { + if (!this.projectId) + throw "Attempted to connect to websocket without a valid Project id"; + return this.projectId; + }, + }); } private ensureValidEnvironment(environment?: string) { @@ -381,14 +174,15 @@ export class BackendClient { "development", "production", ].includes(environment)) { - throw new Error("Project environment is required. Supported environments are development and production."); + throw new Error( + "Project environment is required. Supported environments are development and production.", + ); } } private newOauthClient( { - clientId, - clientSecret, + clientId, clientSecret, }: OAuthCredentials, tokenHost: string, ) { @@ -409,10 +203,12 @@ export class BackendClient { }); } - private async oauthAuthorizationHeader(): Promise { - if (!this.oauthClient) { - throw new Error("OAuth client not configured"); - } + protected authHeaders(): string | Promise { + return this.oauthAuthorizationHeader(); + } + + private async ensureValidOauthToken() { + if (this.oauthToken && !this.oauthToken.expired) return; let attempts = 0; const maxAttempts = 2; // Prevent potential infinite loops @@ -425,6 +221,7 @@ export class BackendClient { try { this.oauthToken = await this.oauthClient.getToken({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new Error(`Failed to obtain OAuth token: ${error.message}`); } @@ -435,137 +232,16 @@ export class BackendClient { if (this.oauthToken.expired()) { throw new Error("Unable to obtain a valid (non-expired) OAuth token"); } - - return `Bearer ${this.oauthToken.token.access_token}`; } - /** - * Makes an HTTP request - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - * @throws Will throw an error if the response status is not OK. - */ - public async makeRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const { - params, - headers: customHeaders, - body, - method = "GET", - baseURL = this.baseApiUrl, - fullResponse = false, - ...fetchOpts - } = opts; - - const url = new URL(`${baseURL}${path}`); - - if (params) { - for (const [ - key, - value, - ] of Object.entries(params)) { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); - } - } - } - - const headers: Record = { - ...customHeaders, - "X-PD-Environment": this.environment, - }; - - let processedBody: string | Buffer | URLSearchParams | FormData | null = null; - - if (body) { - if (body instanceof FormData || body instanceof URLSearchParams || typeof body === "string") { - // For FormData, URLSearchParams, or strings, pass the body as-is - processedBody = body; - } else { - // For objects, assume it's JSON and serialize it - processedBody = JSON.stringify(body); - // Set the Content-Type header to application/json if not already set - headers["Content-Type"] = headers["Content-Type"] || "application/json"; - } - } - - const requestOptions: RequestInit = { - method, - headers, - ...fetchOpts, - }; - - if ([ - "POST", - "PUT", - "PATCH", - ].includes(method.toUpperCase()) && processedBody) { - requestOptions.body = processedBody; - } - - const response: Response = await fetch(url.toString(), requestOptions); - if (fullResponse) { - return response as unknown as T; - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`); - } - - // Attempt to parse JSON, fall back to raw text if it fails - const contentType = response.headers.get("Content-Type"); - if (contentType && contentType.includes("application/json")) { - return await response.json() as T; + private async oauthAuthorizationHeader(): Promise { + if (!this.oauthClient) { + throw new Error("OAuth client not configured"); } - return await response.text() as unknown as T; - } - - /** - * Makes a request to the Pipedream API with appropriate authorization. - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - * @throws Will throw an error if the response status is not OK. - */ - public async makeAuthorizedRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const headers: Record = { - "Content-Type": "application/json", - ...opts.headers, - "Authorization": await this.oauthAuthorizationHeader(), - }; - - return this.makeRequest(path, { - headers, - ...opts, - }); - } + await this.ensureValidOauthToken(); - /** - * Makes a request to the Connect API using Connect authorization. - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - */ - private makeConnectRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const fullPath = `/connect/${this.projectId}${path}`; - return this.makeAuthorizedRequest(fullPath, opts); + return `Bearer ${(this.oauthToken as AccessToken).token.access_token}`; } /** @@ -576,14 +252,16 @@ export class BackendClient { * @returns A promise resolving to the connect token response. * * @example + * * ```typescript - * const tokenResponse = await client.createConnectToken({ - * external_user_id: "external-user-id", - * }); + * const tokenResponse = await client.connectTokenCreate({ + * external_user_id: "external-user-id", }); * console.log(tokenResponse.token); * ``` */ - public createConnectToken(opts: ConnectTokenOpts): Promise { + public createConnectToken( + opts: ConnectTokenCreateOpts, + ): Promise { const body = { ...opts, external_id: opts.external_user_id, @@ -594,30 +272,10 @@ export class BackendClient { }); } - /** - * Retrieves the list of accounts associated with the project. - * - * @param params - The query parameters for retrieving accounts. - * @returns A promise resolving to a list of accounts. - * - * @example - * ```typescript - * const accounts = await client.getAccounts({ include_credentials: 1 }); - * console.log(accounts); - * ``` - */ - public getAccounts(params: GetAccountOpts = {}): Promise { - return this.makeConnectRequest("/accounts", { - method: "GET", - params, - }); - } - /** * Retrieves a specific account by ID. * * @param accountId - The ID of the account to retrieve. - * @param params - The query parameters for retrieving the account. * @returns A promise resolving to the account. * * @example @@ -625,13 +283,6 @@ export class BackendClient { * const account = await client.getAccountById("account-id"); * console.log(account); * ``` - * - * @example - * ```typescript - * const account = await client.getAccountById("account-id", { - * include_credentials: true, - * }); - * console.log(account.credentials); */ public getAccountById( accountId: string, @@ -713,214 +364,4 @@ export class BackendClient { method: "GET", }); } - - /** - * Builds a full workflow URL based on the input. - * - * @param input - Either a full URL (with or without protocol) or just an - * endpoint ID. - * @returns The fully constructed URL. - * @throws If the input is a malformed URL, throws an error with a clear - * message. - * - * @example - * ```typescript - * // Full URL input - * this.buildWorkflowUrl("https://en123.m.pipedream.net"); - * // Returns: "https://en123.m.pipedream.net" - * ``` - * - * @example - * ```typescript - * // Partial URL (without protocol) - * this.buildWorkflowUrl("en123.m.pipedream.net"); - * // Returns: "https://en123.m.pipedream.net" - * ``` - * - * @example - * ```typescript - * // ID only input - * this.buildWorkflowUrl("en123"); - * // Returns: "https://en123.yourdomain.com" (where `yourdomain.com` is set in `workflowDomain`) - * ``` - */ - private buildWorkflowUrl(input: string): string { - const sanitizedInput = input - .trim() - .replace(/[^\w-./:]/g, "") - .toLowerCase(); - if (!sanitizedInput) { - throw new Error("URL or endpoint ID is required"); - } - - let url: string; - const isUrl = sanitizedInput.includes(".") || sanitizedInput.startsWith("http"); - - if (isUrl) { - // Try to parse the input as a URL - let parsedUrl: URL; - try { - const urlString = sanitizedInput.startsWith("http") - ? sanitizedInput - : `https://${sanitizedInput}`; - parsedUrl = new URL(urlString); - } catch (error) { - throw new Error(` - The provided URL is malformed: "${sanitizedInput}". - Please provide a valid URL. - `); - } - - // Validate the hostname to prevent potential DNS rebinding attacks - if (!parsedUrl.hostname.endsWith(this.workflowDomain)) { - throw new Error(`Invalid workflow domain. URL must end with ${this.workflowDomain}`); - } - - url = parsedUrl.href; - } else { - // If the input is an ID, construct the full URL using the base domain - if (!/^e(n|o)[a-z0-9-]+$/i.test(sanitizedInput)) { - throw new Error(` - Invalid endpoint ID format. - Must contain only letters, numbers, and hyphens, and start with either "en" or "eo". - `); - } - - url = `https://${sanitizedInput}.${this.workflowDomain}`; - } - - return url; - } - - /** - * Invokes a workflow using the URL of its HTTP interface(s), by sending an - * - * @param urlOrEndpoint - The URL of the workflow's HTTP interface, or the ID of the endpoint - * @param opts - The options for the request. - * @param opts.body - The body of the request. It must be a JSON-serializable - * value (e.g. an object, null, a string, etc.). - * @param opts.headers - The headers to include in the request. Note that the - * Authorization header will always be set with an OAuth access token - * retrieved by the client. - * @param authType - The type of authorization to use for the request. - * @returns A promise resolving to the response from the workflow. - * - * @example - * ```typescript - * const response: JSON = await client.invokeWorkflow( - * "https://en-your-endpoint.m.pipedream.net", - * { - * body: { - * foo: 123, - * bar: "abc", - * baz: null, - * }, - * headers: { - * "Accept": "application/json", - * }, - * }, - * "oauth", - * ); - * console.log(response); - * ``` - */ - public async invokeWorkflow( - urlOrEndpoint: string, - opts: RequestOptions = {}, - authType: HTTPAuthType = HTTPAuthType.None, - ): Promise { - const { - body, - headers = {}, - } = opts; - - const url = this.buildWorkflowUrl(urlOrEndpoint); - - let authHeader: string | undefined; - switch (authType) { - case HTTPAuthType.StaticBearer: - // It's expected that users will pass their own Authorization header in - // the static bearer case - authHeader = headers["Authorization"]; - break; - case HTTPAuthType.OAuth: - authHeader = await this.oauthAuthorizationHeader(); - break; - default: - break; - } - - return this.makeRequest("", { - ...opts, - baseURL: url, - method: opts.method || "POST", // Default to POST if not specified - headers: authHeader - ? { - ...headers, - "Authorization": authHeader, - } - : headers, - body, - }); - } - - /** - * Invokes a workflow for a Pipedream Connect user in a project - * - * @param url - The URL of the workflow's HTTP interface. - * @param externalUserId — Your end user ID, for whom you're invoking the - * workflow. - * @param opts - The options for the request. - * @param opts.body - The body of the request. It must be a JSON-serializable - * value (e.g. an object, null, a string, etc.). - * @param opts.headers - The headers to include in the request. Note that the - * Authorization header will always be set with an OAuth access token - * retrieved by the client. - * @returns A promise resolving to the response from the workflow. - * - * @example - * ```typescript - * const response = await client.invokeWorkflowForExternalUser( - * "https://your-workflow-url.m.pipedream.net", - * "your-external-user-id", - * { - * body: { - * foo: 123, - * bar: "abc", - * baz: null, - * }, - * headers: { - * "Accept": "application/json", - * }, - * }, - * ); - * console.log(response); - * ``` - */ - public async invokeWorkflowForExternalUser( - url: string, - externalUserId: string, - opts: RequestOptions = {}, - ): Promise { - if (!externalUserId?.trim()) { - throw new Error("External user ID is required"); - } - - if (!url.trim()) { - throw new Error("Workflow URL is required"); - } - - if (!this.oauthClient) { - throw new Error("OAuth is required for invoking workflows for external users. Please pass credentials for a valid OAuth client"); - } - - const { headers = {} } = opts; - return this.invokeWorkflow(url, { - ...opts, - headers: { - ...headers, - "X-PD-External-User-ID": externalUserId, - }, - }, HTTPAuthType.OAuth); // OAuth auth is required for invoking workflows for external users - } } diff --git a/packages/sdk/src/shared/async.ts b/packages/sdk/src/shared/async.ts new file mode 100644 index 0000000000000..6cb3a8b636fde --- /dev/null +++ b/packages/sdk/src/shared/async.ts @@ -0,0 +1,118 @@ +import { createConsumer } from "@rails/actioncable"; +import type { + Consumer, Subscription, +} from "@rails/actioncable"; + +/** + * A generic API response that returns asynchronously. + * See AsyncResponseManager for details. + */ +export type AsyncResponse = { + async_handle: string; +}; + +export type AsyncErrorResponse = { + errors: string[]; +}; + +export type AsyncResponseManagerOpts = { + url: string; + subscriptionParams?: Record; +}; + +type Handle = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: (value: any) => void; + reject: (reason: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + promise: Promise; +}; + +const createHandle = (): Handle => { + const handle: Partial = {}; + handle.promise = new Promise((resolve, reject) => { + handle.resolve = resolve; + handle.reject = reject; + }); + return handle as Handle; +}; + +function randomString(n: number) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return Array(n).fill(0) + .map(() => alphabet[Math.floor(Math.random() * alphabet.length)]) + .join(""); +} + +export abstract class AsyncResponseManager { + protected cable?: Consumer; + protected handles: Record = {}; + protected subscription?: Subscription; + protected opts?: AsyncResponseManagerOpts; + + async connect() { + this.createCable(); + await this.createSubscription(); + } + + createAsyncHandle() { + const asyncHandle = randomString(12); + this.handles[asyncHandle] = createHandle(); + return asyncHandle; + } + + protected createCable(): Consumer { + if (!this.opts?.url) throw "Missing ActionCable url"; + this.cable = createConsumer(this.opts.url); + this.cable.ensureActiveConnection(); + return this.cable; + } + + protected async createSubscription(): Promise { + this.subscription = await new Promise((resolve, reject) => { + this.subscription = this.cable?.subscriptions?.create({ + channel: "AsyncResponseChannel", + ...(this.opts?.subscriptionParams ?? {}), + }, { + connected: () => resolve(this.subscription as Subscription), + rejected: (reason?: string) => reject(reason), + received: (d: { asyncHandle: string; }) => { + const handle = this.handles[d.asyncHandle]; + if (handle) { + handle.resolve(d); + setTimeout(() => delete this.handles[d.asyncHandle], 60000); + } + }, + disconnected: (opts?: { willAttemptReconnect: boolean; }) => { + if (!opts?.willAttemptReconnect) { + for (const asyncHandle of Object.keys(this.handles)) { + const handle: Handle = this.handles[asyncHandle]; + handle.reject("AsyncResponseChannel disconnected"); + } + this.handles = {}; + } + }, + }); + }); + return this.subscription; + } + + async ensureConnected() { + this.cable?.ensureActiveConnection(); + const _opts = await this.getOpts(); + if (!this.opts || JSON.stringify(_opts) !== JSON.stringify(this.opts) || !this.cable?.connection.isOpen()) { + this.opts = _opts; + await this.connect(); + } + } + + protected abstract getOpts(): Promise; + + async waitFor(asyncHandle: string): Promise { + await this.connect(); + const handle = this.handles[asyncHandle] ?? createHandle(); + this.handles[asyncHandle] = handle; + return handle.promise; + } +} + diff --git a/packages/sdk/src/shared/component.ts b/packages/sdk/src/shared/component.ts new file mode 100644 index 0000000000000..5740e6551f5d3 --- /dev/null +++ b/packages/sdk/src/shared/component.ts @@ -0,0 +1,95 @@ +type BaseConfigurableProp = { + name: string; + type: string; + + // XXX don't actually apply to all, fix + label?: string; + description?: string; + optional?: boolean; + disabled?: boolean; + hidden?: boolean; + remoteOptions?: boolean; + useQuery?: boolean; + reloadProps?: boolean; +}; + +// XXX fix duplicating mapping to value type here and with PropValue +type Defaultable = { default?: T; options?: T[]; }; + +export type ConfigurablePropAlert = BaseConfigurableProp & { + type: "alert"; + alertType: "info" | "neutral" | "warning" | "error"; // TODO check the types + content: string; +}; +export type ConfigurablePropAny = BaseConfigurableProp & { + type: "any"; +} & Defaultable; +export type ConfigurablePropApp = BaseConfigurableProp & { + type: "app"; + app: string; +}; +export type ConfigurablePropBoolean = BaseConfigurableProp & { type: "boolean"; }; +export type ConfigurablePropInteger = BaseConfigurableProp & { + type: "integer"; + min?: number; + max?: number; +} & Defaultable; +export type ConfigurablePropObject = BaseConfigurableProp & { + type: "object"; +} & Defaultable; +export type ConfigurablePropString = BaseConfigurableProp & { + type: "string"; + secret?: boolean; +} & Defaultable; +export type ConfigurablePropStringArray = BaseConfigurableProp & { + type: "string[]"; + secret?: boolean; // TODO is this supported +} & Defaultable; // TODO +// | { type: "$.interface.http" } // source only +// | { type: "$.interface.timer" } // source only +// | { type: "$.service.db" } +// | { type: "data_store" } +// | { type: "http_request" } +// | { type: "sql" } -- not in component api docs! +export type ConfigurableProp = + | ConfigurablePropAlert + | ConfigurablePropAny + | ConfigurablePropApp + | ConfigurablePropBoolean + | ConfigurablePropInteger + | ConfigurablePropObject + | ConfigurablePropString + | ConfigurablePropStringArray + | (BaseConfigurableProp & { type: "$.discord.channel"; }); + +export type ConfigurableProps = Readonly; + +export type PropValue = T extends "alert" + ? never + : T extends "any" + ? any + : T extends "app" + ? { authProvisionId: string; } + : T extends "boolean" + ? boolean + : T extends "integer" + ? number + : T extends "object" + ? object + : T extends "string" + ? string + : T extends "string[]" + ? string[] // XXX support arrays differently? + : never; + +export type ConfiguredProps = { + [K in T[number] as K["name"]]?: PropValue +}; + +// as returned by API (configurable_props_json from `afterSave`) +export type V1Component = { + name: string; + key: string; + version: string; + configurable_props: T; +}; diff --git a/packages/sdk/src/shared/index.ts b/packages/sdk/src/shared/index.ts new file mode 100644 index 0000000000000..3b7af2ed4a4a8 --- /dev/null +++ b/packages/sdk/src/shared/index.ts @@ -0,0 +1,844 @@ +// This code is meant to be shared between the browser and server. +import { AsyncResponseManager } from "./async"; +import type { + AsyncResponse, AsyncErrorResponse, +} from "./async"; +import type { V1Component } from "./component"; +export * from "./component"; + +/** + * Options for creating a server-side client. + * This is used to configure the BackendClient instance. + */ +export type ClientOpts = { + /** + * The environment in which the server client is running (e.g., "production", + * "development"). + */ + environment?: string; + + /** + * The API host URL. Used by Pipedream employees. Defaults to + * "api.pipedream.com" if not provided. + */ + apiHost?: string; + + /** + * Base domain for workflows. Used for custom domains: + * https://pipedream.com/docs/workflows/domains + */ + workflowDomain?: string; +}; + +export type AppInfo = { + /** + * ID of the app. Only applies for OAuth apps. + */ + id?: string; + + /** + * The name slug of the target app (see + * https://pipedream.com/docs/connect/quickstart#find-your-apps-name-slug) + */ + name_slug: string; +}; + +/** + * The types of authentication that Pipedream apps support. + */ +export enum AppAuthType { + OAuth = "oauth", + Keys = "keys", + None = "none", +} + +/** + * Response object for a Pipedream app's metadata + */ +export type AppResponse = AppInfo & { + /** + * The human-readable name of the app. + */ + name: string; + + /** + * The authentication type used by the app. + */ + auth_type: AppAuthType; + + /** + * The URL to the app's logo. + */ + img_src: string; + + /** + * A JSON string representing the custom fields for the app. + */ + custom_fields_json: string; + + /** + * Categories associated with the app. + */ + categories: string[]; +}; + +export type ComponentConfigureResponse = { + options: { label: string; value: string; }[]; + string_options: string[]; + errors: string[]; +}; + +/** + * Parameters for the retrieval of apps from the Connect API + */ +export type GetAppsOpts = { + /** + * A search query to filter the apps. + */ + q?: string; +}; + +/** + * Parameters for the retrieval of accounts from the Connect API + */ +export type GetAccountOpts = { + /** + * The ID or name slug of the app, in case you want to only retrieve the + * accounts for a specific app. + */ + app?: string; + + /** + * The ID of the app (if it's an OAuth app), in case you want to only retrieve + * the accounts for a specific app. + */ + oauth_app_id?: string; + + /** + * Whether to retrieve the account's credentials or not. + */ + include_credentials?: boolean; + + /** + * The external user ID associated with the account. + */ + external_user_id?: string; +}; + +/** + * End user account data, returned from the API. + */ +export type Account = { + /** + * The unique ID of the account. + */ + id: string; + + /** + * The name of the account. + */ + name: string; + + /** + * The external ID associated with the account. + */ + external_id: string; + + /** + * Indicates if the account is healthy. Pipedream will periodically retry + * token refresh and test requests for unhealthy accounts. + */ + healthy: boolean; + + /** + * Indicates if the account is no longer active. + */ + dead: boolean; + + /** + * The app associated with the account. + */ + app: AppResponse; + + /** + * The date and time the account was created, an ISO 8601 formatted string. + */ + created_at: string; + + /** + * The date and time the account was last updated, an ISO 8601 formatted + * string. + */ + updated_at: string; + + /** + * The credentials associated with the account, if the `include_credentials` + * parameter was set to true in the request. + */ + credentials?: Record; +}; + +export type ComponentReloadPropsOpts = { + userId: string; + componentId: string; + configuredProps: any; + dynamicPropsId?: string; +}; + +export type ComponentConfigureOpts = { + userId: string; + componentId: string; + propName: string; + configuredProps: any; + dynamicPropsId?: string; + query?: string; +}; + +export type GetComponentOpts = { + q?: string; + app?: string; + componentType?: "trigger" | "action"; +}; + +/** + * Response received after creating a connect token. + */ +export type ConnectTokenResponse = { + /** + * The generated token. + */ + token: string; + + /** + * The expiration time of the token in ISO 8601 format. + */ + expires_at: string; + /** + * The Connect Link URL + */ + connect_link_url: string; +}; + +export type AccountsRequestResponse = { data: Account[]; }; + +export type AppsRequestResponse = { data: AppResponse[]; }; + +export type AppRequestResponse = { data: AppResponse; }; + +export type ComponentsRequestResponse = { + data: Omit[]; +}; + +export type ComponentRequestResponse = { data: V1Component; }; + +/** + * Different ways in which customers can authorize requests to HTTP endpoints + */ +export enum HTTPAuthType { + None = "none", + StaticBearer = "static_bearer_token", + OAuth = "oauth", +} + +/** + * Error response returned by the API in case of an error. + */ +export type ErrorResponse = { + /** + * The error message returned by the API. + */ + error: string; +}; + +/** + * A generic API response that can either be a success or an error. + */ +export type ConnectAPIResponse = T | ErrorResponse; + +/** + * Options for making a request to the Pipedream API. + */ +export interface RequestOptions extends Omit { + /** + * Query parameters to include in the request URL. + */ + params?: Record; + + /** + * Headers to include in the request. + */ + headers?: Record; + + /** + * The URL to make the request to. + */ + baseURL?: string; + + /** + * The body of the request. + */ + body?: Record | string | FormData | URLSearchParams | null; +} + +export interface AsyncRequestOptions extends RequestOptions { + body: { async_handle: string; } & Required; +} + +/** + * A client for interacting with the Pipedream Connect API on the server-side. + */ +export abstract class BaseClient { + protected apiHost: string; + protected abstract asyncResponseManager: AsyncResponseManager; + protected readonly baseApiUrl: string; + protected environment: string; + protected projectId?: string; + protected readonly workflowDomain: string; + + /** + * Constructs a new BackendClient instance. + * + * @param opts - The options for configuring the server client. + */ + constructor(opts: ClientOpts) { + this.environment = opts.environment ?? "production"; + + const { + apiHost = "api.pipedream.com", + workflowDomain = "m.pipedream.net", + } = opts; + this.apiHost = apiHost; + this.baseApiUrl = `https://${apiHost}/v1`; + this.workflowDomain = workflowDomain; + } + + /** + * Makes an HTTP request + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + * @throws Will throw an error if the response status is not OK. + */ + public async makeRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + const { + params, + headers: customHeaders, + body, + method = "GET", + baseURL = this.baseApiUrl, + ...fetchOpts + } = opts; + + const url = new URL(`${baseURL}${path}`); + + if (params) { + for (const [ + key, + value, + ] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + } + } + + const headers: Record = { + ...customHeaders, + "X-PD-Environment": this.environment, + }; + + let processedBody: string | URLSearchParams | FormData | null = null; + + if (body) { + if ( + body instanceof FormData || + body instanceof URLSearchParams || + typeof body === "string" + ) { + // For FormData, URLSearchParams, or strings, pass the body as-is + processedBody = body; + } else { + // For objects, assume it's JSON and serialize it + processedBody = JSON.stringify(body); + // Set the Content-Type header to application/json if not already set + headers["Content-Type"] = headers["Content-Type"] || "application/json"; + } + } + + const requestOptions: RequestInit = { + method, + headers, + ...fetchOpts, + }; + + if ( + [ + "POST", + "PUT", + "PATCH", + ].includes(method.toUpperCase()) && + processedBody + ) { + requestOptions.body = processedBody; + } + + const response: Response = await fetch(url.toString(), requestOptions); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `HTTP error! status: ${response.status}, body: ${errorBody}`, + ); + } + + // Attempt to parse JSON, fall back to raw text if it fails + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.includes("application/json")) { + return (await response.json()) as T; + } + + return (await response.text()) as unknown as T; + } + + protected abstract authHeaders(): string | Promise; + + /** + * Makes a request to the Pipedream API with appropriate authorization. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + * @throws Will throw an error if the response status is not OK. + */ + public async makeAuthorizedRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...opts.headers, + "Authorization": await this.authHeaders(), + }; + + return this.makeRequest(path, { + headers, + ...opts, + }); + } + + /** + * Makes a request to the Connect API using Connect authorization. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + */ + protected makeConnectRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + let fullPath = "/connect"; + if (this.projectId) { + fullPath += `/${this.projectId}`; + } + fullPath += path; + return this.makeAuthorizedRequest(fullPath, opts); + } + + /** + * Makes a request to the Connect API using Connect authorization. + * This version makes an asynchronous request, fulfilled via Websocket. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + */ + protected async makeConnectRequestAsync( + path: string, + opts: AsyncRequestOptions, + ): Promise { + await this.asyncResponseManager.ensureConnected(); + const data = await this.makeConnectRequest< + AsyncResponse | AsyncErrorResponse | T + >(path, opts); + if ("errors" in data && data.errors.length) { + throw new Error(data.errors[0]); + } + if ("async_handle" in data && data.async_handle) { + const result = await this.asyncResponseManager.waitFor( + data.async_handle, + ); + return result; + } + return data as T; + } + + /** + * Retrieves the list of accounts associated with the project. + * + * @param params - The query parameters for retrieving accounts. + * @returns A promise resolving to a list of accounts. + * + * @example + * ```typescript + * const accounts = await client.getAccounts({ include_credentials: true }); + * console.log(accounts); + * ``` + */ + public async getAccounts( + params: GetAccountOpts = {}, + ): Promise { + const resp = await this.makeConnectRequest("/accounts", { + method: "GET", + params, + }); + + return resp; + } + + // XXX only here while need project auth + public async apps(opts?: GetAppsOpts) { + const params: Record = {}; + if (opts?.q) { + params.q = opts.q; + } + const resp = await this.makeAuthorizedRequest( + "/apps", + { + method: "GET", + params, + }, + ); + return resp; + } + + public async app(idOrNameSlug: string) { + const url = `/apps/${idOrNameSlug}`; + const resp = await this.makeAuthorizedRequest(url, { + method: "GET", + }); + return resp; + } + + // XXX only here while need project auth + public async components(opts?: GetComponentOpts) { + const params: Record = { + limit: "20", + }; + if (opts?.app) { + params.app = opts.app; + } + if (opts?.q) { + params.q = opts.q; + } + // XXX can just use /components and ?type instead when supported + let path = "/components"; + if (opts?.componentType === "trigger") { + path = "/triggers"; + } else if (opts?.componentType === "action") { + path = "/actions"; + } + // XXX Is V1Component the correct type for triggers and actions? + const resp = await this.makeConnectRequest(path, { + method: "GET", + params, + }); + return resp; + } + + public async component({ key }: { key: string; }) { + const url = `/components/${key}`; + const resp = await this.makeConnectRequest(url, { + method: "GET", + }); + return resp; + } + + public async componentConfigure(opts: ComponentConfigureOpts) { + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.componentId, + prop_name: opts.propName, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync<{ + options: { label: string; value: string; }[]; + string_options: string[]; + errors: string[]; + }>("/components/configure", { + method: "POST", + body, + }); + } + + public async componentReloadProps(opts: ComponentReloadPropsOpts) { + // RpcActionReloadPropsInput + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.componentId, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync>("/components/props", { + // TODO trigger + method: "POST", + body, + }); + } + + public async actionRun(opts: { + userId: string; + actionId: string; + configuredProps: Record; + dynamicPropsId?: string; + }) { + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.actionId, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync<{ + exports: unknown; + os: unknown[]; + ret: unknown; + }>("/actions/run", { + method: "POST", + body, + }); + } + + /** + * Builds a full workflow URL based on the input. + * + * @param input - Either a full URL (with or without protocol) or just an + * endpoint ID. + * @returns The fully constructed URL. + * @throws If the input is a malformed URL, throws an error with a clear + * message. + * + * @example + * ```typescript + * // Full URL input + * this.buildWorkflowUrl("https://en123.m.pipedream.net"); + * // Returns: "https://en123.m.pipedream.net" + * ``` + * + * @example + * ```typescript + * // Partial URL (without protocol) + * this.buildWorkflowUrl("en123.m.pipedream.net"); + * // Returns: "https://en123.m.pipedream.net" + * ``` + * + * @example + * ```typescript + * // ID only input + * this.buildWorkflowUrl("en123"); + * // Returns: "https://en123.yourdomain.com" (where `yourdomain.com` is set in `workflowDomain`) + * ``` + */ + private buildWorkflowUrl(input: string): string { + const sanitizedInput = input + .trim() + .replace(/[^\w-./:]/g, "") + .toLowerCase(); + if (!sanitizedInput) { + throw new Error("URL or endpoint ID is required"); + } + + let url: string; + const isUrl = + sanitizedInput.includes(".") || sanitizedInput.startsWith("http"); + + if (isUrl) { + // Try to parse the input as a URL + let parsedUrl: URL; + try { + const urlString = sanitizedInput.startsWith("http") + ? sanitizedInput + : `https://${sanitizedInput}`; + parsedUrl = new URL(urlString); + } catch (error) { + throw new Error(` + The provided URL is malformed: "${sanitizedInput}". + Please provide a valid URL. + `); + } + + // Validate the hostname to prevent potential DNS rebinding attacks + if (!parsedUrl.hostname.endsWith(this.workflowDomain)) { + throw new Error( + `Invalid workflow domain. URL must end with ${this.workflowDomain}`, + ); + } + + url = parsedUrl.href; + } else { + // If the input is an ID, construct the full URL using the base domain + if (!/^e(n|o)[a-z0-9-]+$/i.test(sanitizedInput)) { + throw new Error(` + Invalid endpoint ID format. + Must contain only letters, numbers, and hyphens, and start with either "en" or "eo". + `); + } + + url = `https://${sanitizedInput}.${this.workflowDomain}`; + } + + return url; + } + + /** + * Invokes a workflow using the URL of its HTTP interface(s), by sending an + * + * @param urlOrEndpoint - The URL of the workflow's HTTP interface, or the ID of the endpoint + * @param opts - The options for the request. + * @param opts.body - The body of the request. It must be a JSON-serializable + * value (e.g. an object, null, a string, etc.). + * @param opts.headers - The headers to include in the request. Note that the + * Authorization header will always be set with an OAuth access token + * retrieved by the client. + * @param authType - The type of authorization to use for the request. + * @returns A promise resolving to the response from the workflow. + * + * @example + * ```typescript + * const response: JSON = await client.invokeWorkflow( + * "https://en-your-endpoint.m.pipedream.net", + * { + * body: { + * foo: 123, + * bar: "abc", + * baz: null, + * }, + * headers: { + * "Accept": "application/json", + * }, + * }, + * "oauth", + * ); + * console.log(response); + * ``` + */ + public async invokeWorkflow( + urlOrEndpoint: string, + opts: RequestOptions = {}, + authType: HTTPAuthType = HTTPAuthType.None, + ): Promise { + const { + body, headers = {}, + } = opts; + + const url = this.buildWorkflowUrl(urlOrEndpoint); + + let authHeader: string | undefined; + switch (authType) { + case HTTPAuthType.StaticBearer: + // It's expected that users will pass their own Authorization header in + // the static bearer case + authHeader = headers["Authorization"]; + break; + case HTTPAuthType.OAuth: + authHeader = await this.authHeaders(); // TODO How to handle this client side? We should pass the auth even if it's not OAuth + break; + default: + break; + } + + return this.makeRequest("", { + ...opts, + baseURL: url, + method: opts.method || "POST", // Default to POST if not specified + headers: authHeader + ? { + ...headers, + Authorization: authHeader, + } + : headers, + body, + }); + } + + /** + * Invokes a workflow for a Pipedream Connect user in a project + * + * @param url - The URL of the workflow's HTTP interface. + * @param externalUserId — Your end user ID, for whom you're invoking the + * workflow. + * @param opts - The options for the request. + * @param opts.body - The body of the request. It must be a JSON-serializable + * value (e.g. an object, null, a string, etc.). + * @param opts.headers - The headers to include in the request. Note that the + * Authorization header will always be set with an OAuth access token + * retrieved by the client. + * @returns A promise resolving to the response from the workflow. + * + * @example + * ```typescript + * const response = await client.invokeWorkflowForExternalUser( + * "https://your-workflow-url.m.pipedream.net", + * "your-external-user-id", + * { + * body: { + * foo: 123, + * bar: "abc", + * baz: null, + * }, + * headers: { + * "Accept": "application/json", + * }, + * }, + * ); + * console.log(response); + * ``` + */ + public async invokeWorkflowForExternalUser( + url: string, + externalUserId: string, + opts: RequestOptions = {}, + ): Promise { + if (!externalUserId?.trim()) { + throw new Error("External user ID is required"); + } + + if (!url.trim()) { + throw new Error("Workflow URL is required"); + } + + if (!(await this.authHeaders())) { + throw new Error( + // TODO Test that this works with token auth + "OAuth or token is required for invoking workflows for external users. Please pass credentials for a valid OAuth client", + ); + } + + const { headers = {} } = opts; + return this.invokeWorkflow( + url, + { + ...opts, + headers: { + ...headers, + "X-PD-External-User-ID": externalUserId, + }, + }, + HTTPAuthType.OAuth, + ); // OAuth auth is required for invoking workflows for external users + } +} diff --git a/packages/sdk/tsconfig.browser.json b/packages/sdk/tsconfig.browser.json index a0cafbd664f44..83828fc1f2d5f 100644 --- a/packages/sdk/tsconfig.browser.json +++ b/packages/sdk/tsconfig.browser.json @@ -1,8 +1,8 @@ { "compilerOptions": { "module": "ESNext", - "target": "ES6", - "lib": ["ES5", "DOM"], + "target": "es2017", + "lib": ["ES5", "DOM", "es2017"], "declaration": true, "outDir": "dist/browser", "strict": true, diff --git a/packages/sdk/tsconfig.node.json b/packages/sdk/tsconfig.node.json index d53df43f701a9..aac923e1f200d 100644 --- a/packages/sdk/tsconfig.node.json +++ b/packages/sdk/tsconfig.node.json @@ -1,9 +1,9 @@ { "compilerOptions": { "module": "NodeNext", - "target": "ES6", + "target": "es2017", "lib": [ - "ES6" + "es2017" ], "declaration": true, "outDir": "dist/server", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62601dada8f5d..c85f9c9fdaa56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12190,24 +12190,34 @@ importers: packages/sdk: specifiers: + '@rails/actioncable': ^8.0.0 '@types/fetch-mock': ^7.3.8 '@types/jest': ^29.5.13 - '@types/node': ^20.14.9 + '@types/node': ^20.17.6 + '@types/rails__actioncable': ^6.1.11 '@types/simple-oauth2': ^5.0.7 + '@types/ws': ^8.5.13 + commander: ^12.1.0 jest: ^29.7.0 jest-fetch-mock: ^3.0.3 nodemon: ^3.1.7 simple-oauth2: ^5.1.0 ts-jest: ^29.2.5 typescript: ^5.5.2 + ws: ^8.18.0 dependencies: + '@rails/actioncable': 8.0.0 + commander: 12.1.0 simple-oauth2: 5.1.0 + ws: 8.18.0 devDependencies: '@types/fetch-mock': 7.3.8 '@types/jest': 29.5.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 + '@types/rails__actioncable': 6.1.11 '@types/simple-oauth2': 5.0.7 - jest: 29.7.0_@types+node@20.16.1 + '@types/ws': 8.5.13 + jest: 29.7.0_@types+node@20.17.6 jest-fetch-mock: 3.0.3 nodemon: 3.1.7 ts-jest: 29.2.5_q3xqhaztsvh2r5udjscjs67zn4 @@ -17257,7 +17267,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false optional: true @@ -17266,7 +17276,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@grpc/grpc-js/1.9.9: @@ -17274,7 +17284,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@grpc/proto-loader/0.6.13: @@ -17392,7 +17402,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -17413,14 +17423,14 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -17448,7 +17458,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 dev: true @@ -17475,7 +17485,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -17508,7 +17518,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -17596,7 +17606,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/yargs': 17.0.32 chalk: 4.1.2 dev: true @@ -19807,6 +19817,10 @@ packages: npmlog: 4.1.2 dev: true + /@rails/actioncable/8.0.0: + resolution: {integrity: sha512-9IXyJeaBggOzlD3pF4/yWELdyUWZm/KTyKBRqxNf9laLBXPqxJt3t6fO+X4s0WajMR8cIhzkxvq1gxsXVbn3LA==} + dev: false + /@readme/better-ajv-errors/1.6.0_ajv@8.17.1: resolution: {integrity: sha512-9gO9rld84Jgu13kcbKRU+WHseNhaVt76wYMeRDGsUGYxwJtI3RmEJ9LY9dZCYQGI8eUZLuxb5qDja0nqklpFjQ==} engines: {node: '>=14'} @@ -20017,14 +20031,14 @@ packages: resolution: {integrity: sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw==} engines: {node: '>= 8.9.0', npm: '>= 5.5.1'} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@slack/logger/4.0.0: resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} engines: {node: '>= 18', npm: '>= 8.6.0'} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@slack/types/1.10.0: @@ -21527,8 +21541,8 @@ packages: dependencies: '@supabase/node-fetch': 2.6.15 '@types/phoenix': 1.6.2 - '@types/ws': 8.5.12 - ws: 8.16.0 + '@types/ws': 8.5.13 + ws: 8.18.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -21660,7 +21674,7 @@ packages: resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==} dependencies: '@types/connect': 3.4.36 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/cacheable-request/6.0.3: @@ -21679,7 +21693,7 @@ packages: /@types/connect/3.4.36: resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/debug/4.1.12: @@ -21691,7 +21705,7 @@ packages: /@types/duplexify/3.6.2: resolution: {integrity: sha512-2/0R4riyD/OS6GNJLIhwRaj+8ZbxHUZl3I0a3PHwH7zhZEEAACUWjzaBrY1qVWckueZ5pouDRP0UxX6P8Hzfww==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/estree-jsx/1.0.5: @@ -21707,7 +21721,7 @@ packages: /@types/express-serve-static-core/4.17.37: resolution: {integrity: sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/qs': 6.9.8 '@types/range-parser': 1.2.5 '@types/send': 0.17.2 @@ -21737,20 +21751,20 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/glob/8.1.0: resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/graceful-fs/4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: true /@types/hast/2.3.10: @@ -21770,7 +21784,7 @@ packages: /@types/is-stream/1.1.0: resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/istanbul-lib-coverage/2.0.6: @@ -21813,13 +21827,13 @@ packages: /@types/jsonwebtoken/8.5.9: resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/keyv/3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/linkify-it/3.0.3: @@ -21923,6 +21937,11 @@ packages: dependencies: undici-types: 6.19.8 + /@types/node/20.17.6: + resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + dependencies: + undici-types: 6.19.8 + /@types/node/20.9.2: resolution: {integrity: sha512-WHZXKFCEyIUJzAwh3NyyTHYSR35SevJ6mZ1nWwJafKtiQbqRTIKSRcw3Ma3acqgsent3RRDqeVwpHntMk+9irg==} dependencies: @@ -21944,6 +21963,10 @@ packages: resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} dev: false + /@types/rails__actioncable/6.1.11: + resolution: {integrity: sha512-L6A3Rg6sGsv2cqalOgdOmyFvL1Pw69Mg0WuG6NtY9chzabhtkiSFY5fczo72mqRGezrMvl0Jy80v+N719CW+Tg==} + dev: true + /@types/range-parser/1.2.5: resolution: {integrity: sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==} dev: false @@ -21952,7 +21975,7 @@ packages: resolution: {integrity: sha512-HuihY1+Vss5RS9ZHzRyTGIzwPTdrJBkCm/mAeLRYrOQu/MGqyezKXWOK1VhCnR+SDbp9G2mRUP+OVEqCrzpcfA==} dependencies: '@types/caseless': 0.12.4 - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/tough-cookie': 4.0.4 form-data: 2.5.1 dev: false @@ -21960,7 +21983,7 @@ packages: /@types/responselike/1.0.1: resolution: {integrity: sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/retry/0.12.0: @@ -21971,7 +21994,7 @@ packages: resolution: {integrity: sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==} dependencies: '@types/glob': 8.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/sax/1.2.5: @@ -21988,7 +22011,7 @@ packages: resolution: {integrity: sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==} dependencies: '@types/mime': 1.3.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/serve-static/1.15.3: @@ -21996,7 +22019,7 @@ packages: dependencies: '@types/http-errors': 2.0.2 '@types/mime': 3.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/simple-oauth2/5.0.7: @@ -22025,7 +22048,7 @@ packages: /@types/tunnel/0.0.3: resolution: {integrity: sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/unist/2.0.10: @@ -22054,20 +22077,19 @@ packages: /@types/whatwg-url/8.2.2: resolution: {integrity: sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/webidl-conversions': 7.0.1 dev: false - /@types/ws/8.5.12: - resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + /@types/ws/8.5.13: + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} dependencies: - '@types/node': 20.16.1 - dev: false + '@types/node': 20.17.6 /@types/ws/8.5.3: resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/yargs-parser/21.0.3: @@ -22084,7 +22106,7 @@ packages: resolution: {integrity: sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==} requiresBuild: true dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false optional: true @@ -24097,6 +24119,11 @@ packages: engines: {node: '>=16'} dev: false + /commander/12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + dev: false + /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -24365,7 +24392,7 @@ packages: - ts-node dev: true - /create-jest/29.7.0_@types+node@20.16.1: + /create-jest/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -24374,7 +24401,7 @@ packages: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -28846,7 +28873,7 @@ packages: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -28895,7 +28922,7 @@ packages: - ts-node dev: true - /jest-cli/29.7.0_@types+node@20.16.1: + /jest-cli/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -28909,10 +28936,10 @@ packages: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0_@types+node@20.16.1 + create-jest: 29.7.0_@types+node@20.17.6 exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -28990,7 +29017,7 @@ packages: - supports-color dev: true - /jest-config/29.7.0_@types+node@20.16.1: + /jest-config/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -29005,7 +29032,7 @@ packages: '@babel/core': 7.25.2 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 babel-jest: 29.7.0_@babel+core@7.25.2 chalk: 4.1.2 ci-info: 3.9.0 @@ -29115,7 +29142,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -29145,7 +29172,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.16.1 + '@types/node': 20.17.6 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -29206,7 +29233,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-util: 29.7.0 dev: true @@ -29261,7 +29288,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -29292,7 +29319,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 @@ -29344,7 +29371,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -29369,7 +29396,7 @@ packages: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -29381,7 +29408,7 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -29408,7 +29435,7 @@ packages: - ts-node dev: true - /jest/29.7.0_@types+node@20.16.1: + /jest/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -29421,7 +29448,7 @@ packages: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0_@types+node@20.16.1 + jest-cli: 29.7.0_@types+node@20.17.6 transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -33157,7 +33184,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 4.0.0 dev: false optional: true @@ -33178,7 +33205,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 4.0.0 dev: false optional: true @@ -33198,7 +33225,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -33217,7 +33244,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -33236,7 +33263,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -36012,7 +36039,7 @@ packages: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0_@types+node@20.16.1 + jest: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -37375,6 +37402,19 @@ packages: optional: true dev: false + /ws/8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /ws/8.7.0: resolution: {integrity: sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg==} engines: {node: '>=10.0.0'}