diff --git a/.changeset/dull-starfishes-scream.md b/.changeset/dull-starfishes-scream.md new file mode 100644 index 000000000..4656ee961 --- /dev/null +++ b/.changeset/dull-starfishes-scream.md @@ -0,0 +1,5 @@ +--- +"@logto/connector-smsaero": minor +--- + +Add SMSAero connector diff --git a/packages/connectors/connector-smsaero/README.md b/packages/connectors/connector-smsaero/README.md new file mode 100644 index 000000000..572d4697f --- /dev/null +++ b/packages/connectors/connector-smsaero/README.md @@ -0,0 +1,64 @@ +# SMSAero short message service connector + +The official Logto connector for SMSAero short message service. + +**Table of contents** + +- [SMSAero short message service connector](#smsaero-short-message-service-connector) + - [Register account](#register-account) + - [Set up senders' phone numbers](#set-up-senders-phone-numbers) + - [Get account credentials](#get-account-credentials) + - [Compose the connector JSON](#compose-the-connector-json) + - [Test SMSAero connector](#test-smsaero-connector) + - [Config types](#config-types) + - [Reference](#reference) + +## Register account + +Create a new account on [SMSAero](https://smsaero.ru/). (Jump to the next step if you already have one.) + +## Get account credentials + +We will need the API credentials to make the connector work. Let's begin from +the [API and SMPP](https://smsaero.ru/cabinet/settings/apikey/). + +Copy "API-key" or generate new one. + +## Compose the connector JSON + +Fill out the _email_, _apiKey_ and _senderName_ fields with your email, API key and sender name. + +You can fill sender name with "SMSAero" to use default sender name provided by SMSAero. + +You can add multiple SMS connector templates for different cases. Here is an example of adding a single template: + +- Fill out the `content` field with arbitrary string-typed contents. Do not forget to leave `{{code}}` placeholder for + random verification code. +- Fill out the `usageType` field with either `Register`, `SignIn`, `ForgotPassword`, `Generic` for different use cases. + In order to enable full user flows, templates with usageType `Register`, `SignIn`, `ForgotPassword` and `Generic` are + required. + +### Test SMSAero connector + +You can enter a phone number and click on "Send" to see whether the settings can work before "Save and Done". + +That's it. Don't forget +to [Enable connector in sign-in experience](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-sms-or-email-passwordless-sign-in). + +### Config types + +| Name | Type | +|------------|-------------| +| email | string | +| apiKey | string | +| senderName | string | +| templates | Templates[] | + +| Template Properties | Type | Enum values | +|---------------------|-------------|---------------------------------------------------------| +| content | string | N/A | +| usageType | enum string | 'Register' \| 'SignIn' \| 'ForgotPassword' \| 'Generic' | + +## Reference + +- [SMSAero API Documentation](https://smsaero.ru/integration/documentation/api/) diff --git a/packages/connectors/connector-smsaero/logo.svg b/packages/connectors/connector-smsaero/logo.svg new file mode 100644 index 000000000..e3758e9e2 --- /dev/null +++ b/packages/connectors/connector-smsaero/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/connectors/connector-smsaero/package.json b/packages/connectors/connector-smsaero/package.json new file mode 100644 index 000000000..79b4b5fdc --- /dev/null +++ b/packages/connectors/connector-smsaero/package.json @@ -0,0 +1,51 @@ +{ + "name": "@logto/connector-smsaero", + "version": "1.0.0", + "description": "SMSAero connector implementation.", + "author": "Danil Tankov ", + "dependencies": { + "@logto/connector-kit": "workspace:^1.1.0" + }, + "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", + "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "pnpm build:test && pnpm test:only", + "test:ci": "pnpm test:only --silent --coverage", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": "^18.12.0" + }, + "eslintConfig": { + "extends": "@silverhand", + "settings": { + "import/core-modules": [ + "@silverhand/essentials", + "got", + "nock", + "snakecase-keys", + "zod" + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/connectors/connector-smsaero/src/constant.ts b/packages/connectors/connector-smsaero/src/constant.ts new file mode 100644 index 000000000..71a6cdbb6 --- /dev/null +++ b/packages/connectors/connector-smsaero/src/constant.ts @@ -0,0 +1,70 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorConfigFormItemType } from '@logto/connector-kit'; + +export const endpoint = `https://gate.smsaero.ru/v2/sms/send`; + +export const defaultMetadata: ConnectorMetadata = { + id: 'smsaero-short-message-service', + target: 'smsaero-sms', + platform: null, + name: { + en: 'SMS Aero service', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'SMS Aero offers users to use SMS-mailing in 5 minutes without viewing the contract. Developers are offered a convenient API with accessible classes and 24x7 chat support.', + }, + readme: './README.md', + formItems: [ + { + key: 'email', + label: 'Email', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', + }, + { + key: 'apiKey', + label: 'API Key', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', + }, + { + key: 'senderName', + label: 'Sender Name', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: 'SMSAero', + }, + { + 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-smsaero/src/index.test.ts b/packages/connectors/connector-smsaero/src/index.test.ts new file mode 100644 index 000000000..f2a33ba12 --- /dev/null +++ b/packages/connectors/connector-smsaero/src/index.test.ts @@ -0,0 +1,12 @@ +import createConnector from './index.js'; +import { mockedConfig } from './mock.js'; + +const { jest } = import.meta; + +const getConfig = jest.fn().mockResolvedValue(mockedConfig); + +describe('SMSAero SMS connector', () => { + it('init without throwing errors', async () => { + await expect(createConnector({ getConfig })).resolves.not.toThrow(); + }); +}); diff --git a/packages/connectors/connector-smsaero/src/index.ts b/packages/connectors/connector-smsaero/src/index.ts new file mode 100644 index 000000000..419eb62a3 --- /dev/null +++ b/packages/connectors/connector-smsaero/src/index.ts @@ -0,0 +1,85 @@ +import { assert } from '@silverhand/essentials'; +import { got, HTTPError } from 'got'; + +import type { + CreateConnector, + GetConnectorConfig, + SendMessageFunction, + SmsConnector, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + ConnectorType, + validateConfig, +} from '@logto/connector-kit'; + +import { defaultMetadata, endpoint } from './constant.js'; +import type { PublicParameters, SmsAeroConfig } from './types.js'; +import { smsAeroConfigGuard } from './types.js'; + +function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction { + return async (data, inputConfig) => { + const { to, type, payload } = data; + + const config = inputConfig ?? (await getConfig(defaultMetadata.id)); + validateConfig(config, smsAeroConfigGuard); + + const { email, apiKey, senderName, templates } = config; + + const template = templates.find((template) => template.usageType === type); + + assert( + template, + new ConnectorError( + ConnectorErrorCodes.TemplateNotFound, + `Cannot find template for type: ${type}` + ) + ); + + const parameters: PublicParameters = { + number: to, + sign: senderName, + text: template.content.replace(/{{code}}/g, payload.code), + }; + + const auth = Buffer.from(`${email}:${apiKey}`).toString('base64'); + + try { + return await got.post(endpoint, { + headers: { + Authorization: `Basic ${auth}`, + }, + json: parameters, + }); + } 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 createSmsAeroConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Sms, + configGuard: smsAeroConfigGuard, + sendMessage: sendMessage(getConfig), + }; +}; + +export default createSmsAeroConnector; diff --git a/packages/connectors/connector-smsaero/src/mock.ts b/packages/connectors/connector-smsaero/src/mock.ts new file mode 100644 index 000000000..055f32ace --- /dev/null +++ b/packages/connectors/connector-smsaero/src/mock.ts @@ -0,0 +1,17 @@ +import type { SmsAeroConfig } from './types.js'; + +const mockedEmail = 'test@test.com'; +const mockedApiKey = 'api-key'; +const mockedSenderName = 'sender-name'; + +export const mockedConfig: SmsAeroConfig = { + email: mockedEmail, + apiKey: mockedApiKey, + senderName: mockedSenderName, + templates: [ + { + usageType: 'Test', + content: 'This is for testing purposes only. Your verification code is {{code}}.', + }, + ], +}; diff --git a/packages/connectors/connector-smsaero/src/types.ts b/packages/connectors/connector-smsaero/src/types.ts new file mode 100644 index 000000000..dfdb5aea2 --- /dev/null +++ b/packages/connectors/connector-smsaero/src/types.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * @doc https://smsaero.ru/integration/documentation/api/ + */ + +export type PublicParameters = { + number: string; + sign: string; + text: string; +}; + +/** + * UsageType here is used to specify the use case of the template, can be either + * 'Register', 'SignIn', 'ForgotPassword', 'Generic' or 'Test'. + */ +const requiredTemplateUsageTypes = ['Register', 'SignIn', 'ForgotPassword', 'Generic']; + +const templateGuard = z.object({ + usageType: z.string(), + content: z.string(), +}); + +export const smsAeroConfigGuard = z.object({ + email: z.string().email(), + apiKey: z.string(), + senderName: 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 SmsAeroConfig = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4737118a3..d910f98ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2263,6 +2263,85 @@ importers: specifier: ^5.0.0 version: 5.0.2 + packages/connectors/connector-smsaero: + dependencies: + '@logto/connector-kit': + specifier: workspace:^1.1.0 + version: link:../../toolkit/connector-kit + '@silverhand/essentials': + specifier: ^2.5.0 + version: 2.5.0 + got: + specifier: ^13.0.0 + version: 13.0.0 + snakecase-keys: + specifier: ^5.4.4 + version: 5.4.4 + zod: + specifier: ^3.20.2 + version: 3.20.2 + devDependencies: + '@jest/types': + specifier: ^29.5.0 + version: 29.5.0 + '@rollup/plugin-commonjs': + specifier: ^25.0.0 + version: 25.0.0(rollup@3.8.0) + '@rollup/plugin-json': + specifier: ^6.0.0 + version: 6.0.0(rollup@3.8.0) + '@rollup/plugin-node-resolve': + specifier: ^15.0.1 + version: 15.0.1(rollup@3.8.0) + '@rollup/plugin-typescript': + specifier: ^11.0.0 + version: 11.0.0(rollup@3.8.0)(typescript@5.0.2) + '@silverhand/eslint-config': + specifier: 3.0.1 + version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2) + '@silverhand/ts-config': + specifier: 3.0.0 + version: 3.0.0(typescript@5.0.2) + '@types/jest': + specifier: ^29.4.0 + version: 29.4.0 + '@types/node': + specifier: ^18.11.18 + version: 18.11.18 + '@types/supertest': + specifier: ^2.0.11 + version: 2.0.11 + eslint: + specifier: ^8.34.0 + version: 8.34.0 + jest: + specifier: ^29.5.0 + version: 29.5.0(@types/node@18.11.18) + jest-matcher-specific-error: + specifier: ^1.0.0 + version: 1.0.0 + lint-staged: + specifier: ^13.0.0 + version: 13.0.0 + nock: + specifier: ^13.2.2 + version: 13.2.2 + prettier: + specifier: ^2.8.2 + version: 2.8.4 + rollup: + specifier: ^3.8.0 + version: 3.8.0 + rollup-plugin-summary: + specifier: ^2.0.0 + version: 2.0.0(rollup@3.8.0) + supertest: + specifier: ^6.2.2 + version: 6.2.2 + typescript: + specifier: ^5.0.0 + version: 5.0.2 + packages/connectors/connector-smtp: dependencies: '@logto/connector-kit': @@ -10745,7 +10824,7 @@ packages: dev: false /concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true /concat-stream@2.0.0: @@ -11328,7 +11407,7 @@ packages: dev: true /dezalgo@1.0.3: - resolution: {integrity: sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=} + resolution: {integrity: sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==} dependencies: asap: 2.0.6 wrappy: 1.0.2