diff --git a/packages/connectors/connector-gatewayapi-sms/README.md b/packages/connectors/connector-gatewayapi-sms/README.md new file mode 100644 index 000000000..58a21165d --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/README.md @@ -0,0 +1,32 @@ +# GatewayAPI SMS connector + +The official Logto connector for GatewayAPI SMS. + +## Get started + +GatewayAPI is a cloud service provider in Europe, offering many cloud services, including SMS (short message service). GatewayAPI SMS Connector is a plugin provided by the Logto team to call the GatewayAPI SMS service, with the help of which Logto end-users can register and sign in to their Logto account via SMS verification code. + +## Set up in GatewayAPI + +> 💡 **Tip** +> +> You can skip some sections if you have already finished. + +### Create an GatewayAPI account + +Go to the [GatewayAPI website](https://www.gatewayapi.com/) and register your GatewayAPI account if you don't have one. + +### Enable account + +You may need to enable your account before using the SMS service. You can contact the GatewayAPI customer service to enable your account. + +### Get API token + +Go to the API Keys page from the GatewayAPI console, and find the API token or create a new API token. + +## Set up in Logto + +1. **Endpoint**: If your GatewayAPI account is in the EU region, you should use the endpoint `https://gatewayapi.com/rest/mtsms`. If your GatewayAPI account is in the US region, you should use the endpoint `https://gatewayapi.com/rest/mtsms`. +2. **API Token**: The API token you created in the previous step. +3. **Sender**: The sender you want to use to send the SMS. +4. **Templates**: The templates you want to use to send the SMS, you can use the default templates or modify them as needed. diff --git a/packages/connectors/connector-gatewayapi-sms/logo.svg b/packages/connectors/connector-gatewayapi-sms/logo.svg new file mode 100644 index 000000000..a0ea14bbb --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/connectors/connector-gatewayapi-sms/package.json b/packages/connectors/connector-gatewayapi-sms/package.json new file mode 100644 index 000000000..ceaa7d593 --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/package.json @@ -0,0 +1,69 @@ +{ + "name": "@logto/connector-gatewayapi-sms", + "version": "0.0.0", + "description": "GatewayAPI SMS connector implementation.", + "author": "Silverhand Inc. ", + "dependencies": { + "@logto/connector-kit": "workspace:^4.0.0", + "@silverhand/essentials": "^2.9.1", + "got": "^14.0.0", + "snakecase-keys": "^8.0.1", + "zod": "^3.23.8" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "exports": "./lib/index.js", + "license": "MPL-2.0", + "type": "module", + "files": [ + "lib", + "docs", + "logo.svg", + "logo-dark.svg" + ], + "scripts": { + "precommit": "lint-staged", + "check": "tsc --noEmit", + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "vitest src", + "test:ci": "pnpm run test --silent --coverage", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": "^20.9.0" + }, + "eslintConfig": { + "extends": "@silverhand", + "settings": { + "import/core-modules": [ + "@silverhand/essentials", + "got", + "nock", + "snakecase-keys", + "zod" + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^2.0.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "supertest": "^7.0.0", + "tsup": "^8.1.0", + "typescript": "^5.5.3", + "vitest": "^2.0.0" + } +} diff --git a/packages/connectors/connector-gatewayapi-sms/src/constant.ts b/packages/connectors/connector-gatewayapi-sms/src/constant.ts new file mode 100644 index 000000000..4cf3c2d1e --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/src/constant.ts @@ -0,0 +1,70 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorConfigFormItemType } from '@logto/connector-kit'; + +export const endpoint = 'https://api.twilio.com/2010-04-01/Accounts/{{accountSID}}/Messages.json'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'gatewayapi-sms', + target: 'gatewayapi-sms', + platform: null, + name: { + en: 'GatewayAPI SMS Service', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'GatewayAPI accelerates development by removing the learning curve and guesswork, so you can get down to building right away with our APIs.', + }, + readme: './README.md', + formItems: [ + { + key: 'endpoint', + label: 'Endpoint', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: 'https://gatewayapi.com/rest/mtsms', + defaultValue: 'https://gatewayapi.com/rest/mtsms', + }, + { + key: 'apiToken', + label: 'API Token', + type: ConnectorConfigFormItemType.Text, + required: true, + }, + { + key: 'sender', + label: 'Sender', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: 'ExampleSMS', + }, + { + key: 'templates', + label: 'Templates', + type: ConnectorConfigFormItemType.Json, + required: true, + defaultValue: [ + { + usageType: 'SignIn', + content: + 'Your Logto sign-in verification code is {{code}}. The code will remain active for 10 minutes.', + }, + { + usageType: 'Register', + content: + 'Your Logto sign-up verification code is {{code}}. The code will remain active for 10 minutes.', + }, + { + usageType: 'ForgotPassword', + content: + 'Your Logto password change verification code is {{code}}. The code will remain active for 10 minutes.', + }, + { + usageType: 'Generic', + content: + 'Your Logto verification code is {{code}}. The code will remain active for 10 minutes.', + }, + ], + }, + ], +}; diff --git a/packages/connectors/connector-gatewayapi-sms/src/index.test.ts b/packages/connectors/connector-gatewayapi-sms/src/index.test.ts new file mode 100644 index 000000000..dd2fb941e --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/src/index.test.ts @@ -0,0 +1,10 @@ +import createConnector from './index.js'; +import { mockedConfig } from './mock.js'; + +const getConfig = vi.fn().mockResolvedValue(mockedConfig); + +describe('GatewayAPI SMS connector', () => { + it('init without throwing errors', async () => { + await expect(createConnector({ getConfig })).resolves.not.toThrow(); + }); +}); diff --git a/packages/connectors/connector-gatewayapi-sms/src/index.ts b/packages/connectors/connector-gatewayapi-sms/src/index.ts new file mode 100644 index 000000000..5b82ea049 --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/src/index.ts @@ -0,0 +1,81 @@ +import { assert } from '@silverhand/essentials'; +import { got, HTTPError } from 'got'; + +import type { + GetConnectorConfig, + SendMessageFunction, + CreateConnector, + SmsConnector, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + replaceSendMessageHandlebars, +} from '@logto/connector-kit'; + +import { defaultMetadata } from './constant.js'; +import { gatewayApiSmsConfigGuard, type GatewayApiSmsPayload } from './types.js'; + +const sendMessage = + (getConfig: GetConnectorConfig): SendMessageFunction => + async (data, inputConfig) => { + const { to, type, payload } = data; + const config = inputConfig ?? (await getConfig(defaultMetadata.id)); + validateConfig(config, gatewayApiSmsConfigGuard); + const { endpoint, apiToken, sender, templates } = config; + const template = templates.find((template) => template.usageType === type); + + assert( + template, + new ConnectorError( + ConnectorErrorCodes.TemplateNotFound, + `Cannot find template for type: ${type}` + ) + ); + + const encodedAuth = Buffer.from(`${apiToken}:`).toString('base64'); + const body: GatewayApiSmsPayload = { + sender, + message: replaceSendMessageHandlebars(template.content, payload), + recipients: [{ msisdn: to }], + }; + + try { + return await got.post(endpoint, { + headers: { + Authorization: `Basic ${encodedAuth}`, + }, + json: body, + }); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { + response: { body: rawBody }, + } = error; + assert( + typeof rawBody === 'string', + new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + `Invalid response raw body type: ${typeof rawBody}` + ) + ); + + throw new ConnectorError(ConnectorErrorCodes.General, rawBody); + } + + throw error; + } + }; + +const createGatewayApiSmsConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Sms, + configGuard: gatewayApiSmsConfigGuard, + sendMessage: sendMessage(getConfig), + }; +}; + +export default createGatewayApiSmsConnector; diff --git a/packages/connectors/connector-gatewayapi-sms/src/mock.ts b/packages/connectors/connector-gatewayapi-sms/src/mock.ts new file mode 100644 index 000000000..eac8bd6c9 --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/src/mock.ts @@ -0,0 +1,17 @@ +import type { GatewayApiSmsConfig } from './types.js'; + +const mockedEndpoint = 'https://gatewayapi.com/rest/mtsms'; +const mockedApiToken = 'api-token'; +const mockedSender = 'sender'; + +export const mockedConfig: GatewayApiSmsConfig = { + endpoint: mockedEndpoint, + apiToken: mockedApiToken, + sender: mockedSender, + templates: [ + { + usageType: 'Generic', + content: 'This is for testing purposes only. Your verification code is {{code}}.', + }, + ], +}; diff --git a/packages/connectors/connector-gatewayapi-sms/src/types.ts b/packages/connectors/connector-gatewayapi-sms/src/types.ts new file mode 100644 index 000000000..a0eb476a5 --- /dev/null +++ b/packages/connectors/connector-gatewayapi-sms/src/types.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +/** + * UsageType here is used to specify the use case of the template, can be either + * 'Register', 'SignIn', 'ForgotPassword', 'Generic'. + */ +const requiredTemplateUsageTypes = ['Register', 'SignIn', 'ForgotPassword', 'Generic']; + +const templateGuard = z.object({ + usageType: z.string(), + content: z.string(), +}); + +export const gatewayApiSmsConfigGuard = z.object({ + endpoint: z.string(), + apiToken: z.string(), + sender: z.string(), + templates: z.array(templateGuard).refine( + (templates) => + requiredTemplateUsageTypes.every((requiredType) => + templates.map((template) => template.usageType).includes(requiredType) + ), + (templates) => ({ + message: `Template with UsageType (${requiredTemplateUsageTypes + .filter( + (requiredType) => !templates.map((template) => template.usageType).includes(requiredType) + ) + .join(', ')}) should be provided!`, + }) + ), +}); + +export type GatewayApiSmsConfig = z.infer; + +export type GatewayApiSmsPayload = { + sender: string; + message: string; + recipients: Array<{ msisdn: string }>; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0386d7edf..8219ba2ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -933,6 +933,64 @@ importers: specifier: ^2.0.0 version: 2.0.0(@types/node@20.11.20)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8) + packages/connectors/connector-gatewayapi-sms: + dependencies: + '@logto/connector-kit': + specifier: workspace:^4.0.0 + version: link:../../toolkit/connector-kit + '@silverhand/essentials': + specifier: ^2.9.1 + version: 2.9.1 + got: + specifier: ^14.0.0 + version: 14.0.0 + snakecase-keys: + specifier: ^8.0.1 + version: 8.0.1 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@silverhand/eslint-config': + specifier: 6.0.1 + version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3) + '@silverhand/ts-config': + specifier: 6.0.0 + version: 6.0.0(typescript@5.5.3) + '@types/node': + specifier: ^20.11.20 + version: 20.12.7 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@vitest/coverage-v8': + specifier: ^2.0.0 + version: 2.0.0(vitest@2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + lint-staged: + specifier: ^15.0.2 + version: 15.0.2 + nock: + specifier: ^13.3.1 + version: 13.3.1 + prettier: + specifier: ^3.0.0 + version: 3.0.0 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + tsup: + specifier: ^8.1.0 + version: 8.1.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(postcss@8.4.39)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3))(typescript@5.5.3) + typescript: + specifier: ^5.5.3 + version: 5.5.3 + vitest: + specifier: ^2.0.0 + version: 2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8) + packages/connectors/connector-github: dependencies: '@logto/connector-kit': @@ -18809,7 +18867,7 @@ snapshots: debug: 4.3.5 enhanced-resolve: 5.16.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 @@ -18821,7 +18879,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7(supports-color@5.5.0) optionalDependencies: @@ -18860,7 +18918,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3