diff --git a/.changeset/tough-kiwis-confess.md b/.changeset/tough-kiwis-confess.md new file mode 100644 index 000000000..d1ee20f93 --- /dev/null +++ b/.changeset/tough-kiwis-confess.md @@ -0,0 +1,6 @@ +--- +"@logto/connector-kit": major +--- + +major: Remove the deprecated enum MessageType, should all migrate using the new enum VerificationCodeType. +patch: Split the types for connectors into separate files. diff --git a/packages/toolkit/connector-kit/src/index.ts b/packages/toolkit/connector-kit/src/index.ts index 21e578f14..09c24d49a 100644 --- a/packages/toolkit/connector-kit/src/index.ts +++ b/packages/toolkit/connector-kit/src/index.ts @@ -1,8 +1,8 @@ import type { ZodType, ZodTypeDef } from 'zod'; -import { ConnectorError, ConnectorErrorCodes } from './types.js'; +import { ConnectorError, ConnectorErrorCodes } from './types/index.js'; -export * from './types.js'; +export * from './types/index.js'; export function validateConfig( config: unknown, diff --git a/packages/toolkit/connector-kit/src/types.ts b/packages/toolkit/connector-kit/src/types.ts deleted file mode 100644 index 524ef61e4..000000000 --- a/packages/toolkit/connector-kit/src/types.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { LanguageTag } from '@logto/language-kit'; -import { isLanguageTag } from '@logto/language-kit'; -import type Client from '@withtyped/client'; -import type { BaseRoutes, Router } from '@withtyped/server'; -import type { ZodType } from 'zod'; -import { z } from 'zod'; - -// MARK: Foundation -export enum ConnectorType { - Email = 'Email', - Sms = 'Sms', - Social = 'Social', -} - -export enum ConnectorPlatform { - Native = 'Native', - Universal = 'Universal', - Web = 'Web', -} - -export const i18nPhrasesGuard: ZodType = z - .object({ en: z.string() }) - .and(z.record(z.string())) - .refine((i18nObject) => { - const keys = Object.keys(i18nObject); - - if (!keys.includes('en')) { - return false; - } - - for (const value of keys) { - if (!isLanguageTag(value)) { - return false; - } - } - - return true; - }); - -type I18nPhrases = { en: string } & { - [K in Exclude]?: string; -}; - -export enum ConnectorErrorCodes { - General = 'general', - InvalidMetadata = 'invalid_metadata', - UnexpectedType = 'unexpected_type', - InvalidConfigGuard = 'invalid_config_guard', - InvalidRequestParameters = 'invalid_request_parameters', - InsufficientRequestParameters = 'insufficient_request_parameters', - InvalidConfig = 'invalid_config', - InvalidResponse = 'invalid_response', - /** The template is not found for the given type. */ - TemplateNotFound = 'template_not_found', - /** The template type is not supported by the connector. */ - TemplateNotSupported = 'template_not_supported', - RateLimitExceeded = 'rate_limit_exceeded', - NotImplemented = 'not_implemented', - SocialAuthCodeInvalid = 'social_auth_code_invalid', - SocialAccessTokenInvalid = 'social_invalid_access_token', - SocialIdTokenInvalid = 'social_invalid_id_token', - AuthorizationFailed = 'authorization_failed', -} - -export class ConnectorError extends Error { - public code: ConnectorErrorCodes; - public data: unknown; - - constructor(code: ConnectorErrorCodes, data?: unknown) { - const message = `ConnectorError: ${data ? JSON.stringify(data) : code}`; - super(message); - this.code = code; - this.data = typeof data === 'string' ? { message: data } : data; - } -} - -export enum VerificationCodeType { - SignIn = 'SignIn', - Register = 'Register', - ForgotPassword = 'ForgotPassword', - /** @deprecated */ - Continue = 'Continue', - Generic = 'Generic', - /** @deprecated Use `Generic` type template for sending test sms/email use case */ - Test = 'Test', -} - -export const verificationCodeTypeGuard = z.nativeEnum(VerificationCodeType); - -// Enum is string actually, keep this exported until GA for compatibility. -/** @deprecated Use `VerificationCodeType` instead. */ -export enum MessageType { - SignIn = 'SignIn', - Register = 'Register', - ForgotPassword = 'ForgotPassword', - Continue = 'Continue', - Test = 'Test', -} - -/** @deprecated Use `verificationCodeTypeGuard` instead. */ -export const messageTypesGuard = verificationCodeTypeGuard; - -export enum ConnectorConfigFormItemType { - Text = 'Text', - Number = 'Number', - MultilineText = 'MultilineText', - Switch = 'Switch', - Select = 'Select', - Json = 'Json', -} - -const baseConfigFormItem = { - key: z.string(), - label: z.string(), - placeholder: z.string().optional(), - required: z.boolean().optional(), - defaultValue: z.unknown().optional(), - showConditions: z - .array(z.object({ targetKey: z.string(), expectValue: z.unknown().optional() })) - .optional(), - description: z.string().optional(), - tooltip: z.string().optional(), - isConfidential: z.boolean().optional(), // For `Text` type only. -}; - -const connectorConfigFormItemGuard = z.discriminatedUnion('type', [ - z.object({ - type: z.literal(ConnectorConfigFormItemType.Select), - selectItems: z.array(z.object({ value: z.string(), title: z.string() })), - ...baseConfigFormItem, - }), - z.object({ - type: z.enum([ - ConnectorConfigFormItemType.Text, - ConnectorConfigFormItemType.Number, - ConnectorConfigFormItemType.MultilineText, - ConnectorConfigFormItemType.Switch, - ConnectorConfigFormItemType.Json, - ]), - ...baseConfigFormItem, - }), -]); - -export type ConnectorConfigFormItem = z.infer; - -export const connectorMetadataGuard = z - .object({ - id: z.string(), - target: z.string(), - platform: z.nativeEnum(ConnectorPlatform).nullable(), - name: i18nPhrasesGuard, - logo: z.string(), - logoDark: z.string().nullable(), - description: i18nPhrasesGuard, - isStandard: z.boolean().optional(), - readme: z.string(), - configTemplate: z.string().optional(), - formItems: connectorConfigFormItemGuard.array().optional(), - }) - .catchall(z.unknown()); - -export const configurableConnectorMetadataGuard = connectorMetadataGuard - .pick({ - target: true, - name: true, - logo: true, - logoDark: true, - }) - .partial(); - -export type ConnectorMetadata = z.infer; - -export type ConfigurableConnectorMetadata = z.infer; - -export const connectorSessionGuard = z - .object({ - nonce: z.string(), - redirectUri: z.string(), - connectorId: z.string(), - connectorFactoryId: z.string(), - jti: z.string(), - state: z.string(), - }) - .partial() - /** - * Accept arbitrary unspecified keys so developers who can not publish @logto/connector-kit can more flexibly utilize connector session. - */ - .catchall(z.unknown()); - -export type ConnectorSession = z.infer; - -export type GetSession = () => Promise; - -export type SetSession = (storage: ConnectorSession) => Promise; - -export type BaseConnector = { - type: Type; - metadata: ConnectorMetadata; - configGuard: ZodType; -}; - -export type CreateConnector< - T extends AllConnector, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - U extends Router = Router, -> = (options: { - getConfig: GetConnectorConfig; - getCloudServiceClient?: GetCloudServiceClient; -}) => Promise; - -export type GetConnectorConfig = (id: string) => Promise; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type GetCloudServiceClient> = () => Promise< - Client ->; - -export type AllConnector = SmsConnector | EmailConnector | SocialConnector; - -// MARK: SMS + Email connector -export type SmsConnector = BaseConnector & { - sendMessage: SendMessageFunction; - getUsage?: GetUsageFunction; -}; - -export type EmailConnector = BaseConnector & { - sendMessage: SendMessageFunction; - getUsage?: GetUsageFunction; -}; - -export const urlRegEx = - /(https?:\/\/)?(?:www\.)?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b[\w#%&()+./:=?@~-]*/; - -export const emailServiceBrandingGuard = z - .object({ - senderName: z - .string() - .refine((address) => !urlRegEx.test(address), 'DO NOT include URL in the sender name!'), - companyInformation: z - .string() - .refine( - (address) => !urlRegEx.test(address), - 'DO NOT include URL in the company information!' - ), - appLogo: z.string().url(), - }) - .partial(); - -export type EmailServiceBranding = z.infer; - -export type SendMessagePayload = { - /** - * The dynamic verification code to send. - * - * @example '123456' - */ - code: string; -}; - -export const sendMessagePayloadGuard = z.object({ - code: z.string(), -}) satisfies z.ZodType; - -export type SendMessageData = { - to: string; - type: VerificationCodeType; - payload: SendMessagePayload; -}; - -export const sendMessageDataGuard = z.object({ - to: z.string(), - type: verificationCodeTypeGuard, - payload: sendMessagePayloadGuard, -}) satisfies z.ZodType; - -export type SendMessageFunction = (data: SendMessageData, config?: unknown) => Promise; - -export type GetUsageFunction = (startFrom?: Date) => Promise; - -// MARK: Social connector -export type SocialConnector = BaseConnector & { - getAuthorizationUri: GetAuthorizationUri; - getUserInfo: GetUserInfo; - validateSamlAssertion?: ValidateSamlAssertion; -}; - -// This type definition is for SAML connector -export type ValidateSamlAssertion = ( - assertion: Record, - getSession: GetSession, - setSession: SetSession -) => Promise; - -export type GetAuthorizationUri = ( - payload: { - state: string; - redirectUri: string; - connectorId: string; - connectorFactoryId: string; - jti: string; - headers: { userAgent?: string }; - }, - setSession: SetSession -) => Promise; - -export const socialUserInfoGuard = z.object({ - id: z.string(), - email: z.string().optional(), - phone: z.string().optional(), - name: z.string().optional(), - avatar: z.string().optional(), -}); - -export type SocialUserInfo = z.infer; - -export type GetUserInfo = ( - data: unknown, - getSession: GetSession -) => Promise>; - -export enum DemoConnector { - Sms = 'logto-sms', - Social = 'logto-social-demo', -} - -export const demoConnectorIds: readonly string[] = Object.freeze([ - DemoConnector.Sms, - DemoConnector.Social, -]); - -export enum ServiceConnector { - Email = 'logto-email', -} - -export const serviceConnectorIds: readonly string[] = Object.freeze([ServiceConnector.Email]); diff --git a/packages/toolkit/connector-kit/src/types/config-form.ts b/packages/toolkit/connector-kit/src/types/config-form.ts new file mode 100644 index 000000000..85e7dc0a2 --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/config-form.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +export enum ConnectorConfigFormItemType { + Text = 'Text', + Number = 'Number', + MultilineText = 'MultilineText', + Switch = 'Switch', + Select = 'Select', + Json = 'Json', +} + +const baseConfigFormItem = { + key: z.string(), + label: z.string(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + defaultValue: z.unknown().optional(), + showConditions: z + .array(z.object({ targetKey: z.string(), expectValue: z.unknown().optional() })) + .optional(), + description: z.string().optional(), + tooltip: z.string().optional(), + isConfidential: z.boolean().optional(), // For `Text` type only. +}; + +export const connectorConfigFormItemGuard = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(ConnectorConfigFormItemType.Select), + selectItems: z.array(z.object({ value: z.string(), title: z.string() })), + ...baseConfigFormItem, + }), + z.object({ + type: z.enum([ + ConnectorConfigFormItemType.Text, + ConnectorConfigFormItemType.Number, + ConnectorConfigFormItemType.MultilineText, + ConnectorConfigFormItemType.Switch, + ConnectorConfigFormItemType.Json, + ]), + ...baseConfigFormItem, + }), +]); + +export type ConnectorConfigFormItem = z.infer; diff --git a/packages/toolkit/connector-kit/src/types/error.ts b/packages/toolkit/connector-kit/src/types/error.ts new file mode 100644 index 000000000..5c3c8475c --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/error.ts @@ -0,0 +1,32 @@ +export enum ConnectorErrorCodes { + General = 'general', + InvalidMetadata = 'invalid_metadata', + UnexpectedType = 'unexpected_type', + InvalidConfigGuard = 'invalid_config_guard', + InvalidRequestParameters = 'invalid_request_parameters', + InsufficientRequestParameters = 'insufficient_request_parameters', + InvalidConfig = 'invalid_config', + InvalidResponse = 'invalid_response', + /** The template is not found for the given type. */ + TemplateNotFound = 'template_not_found', + /** The template type is not supported by the connector. */ + TemplateNotSupported = 'template_not_supported', + RateLimitExceeded = 'rate_limit_exceeded', + NotImplemented = 'not_implemented', + SocialAuthCodeInvalid = 'social_auth_code_invalid', + SocialAccessTokenInvalid = 'social_invalid_access_token', + SocialIdTokenInvalid = 'social_invalid_id_token', + AuthorizationFailed = 'authorization_failed', +} + +export class ConnectorError extends Error { + public code: ConnectorErrorCodes; + public data: unknown; + + constructor(code: ConnectorErrorCodes, data?: unknown) { + const message = `ConnectorError: ${data ? JSON.stringify(data) : code}`; + super(message); + this.code = code; + this.data = typeof data === 'string' ? { message: data } : data; + } +} diff --git a/packages/toolkit/connector-kit/src/types/foundation.ts b/packages/toolkit/connector-kit/src/types/foundation.ts new file mode 100644 index 000000000..5692bb36a --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/foundation.ts @@ -0,0 +1,19 @@ +import type { ZodType } from 'zod'; + +import { type ConnectorMetadata } from './metadata.js'; + +export enum ConnectorType { + Email = 'Email', + Sms = 'Sms', + Social = 'Social', +} + +/* + SocialConnector, EmailConnector, SmsConnector has dependency on BaseConnector, + so BaseConnector need be defined separately. +*/ +export type BaseConnector = { + type: Type; + metadata: ConnectorMetadata; + configGuard: ZodType; +}; diff --git a/packages/toolkit/connector-kit/src/types/index.ts b/packages/toolkit/connector-kit/src/types/index.ts new file mode 100644 index 000000000..432c270a2 --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/index.ts @@ -0,0 +1,46 @@ +import type Client from '@withtyped/client'; +import type { BaseRoutes, Router } from '@withtyped/server'; + +import { type SmsConnector, type EmailConnector } from './passwordless.js'; +import { type SocialConnector } from './social.js'; + +export * from './config-form.js'; +export * from './error.js'; +export * from './metadata.js'; +export * from './foundation.js'; +export * from './passwordless.js'; +export * from './social.js'; + +export type GetConnectorConfig = (id: string) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GetCloudServiceClient> = () => Promise< + Client +>; + +export type CreateConnector< + T extends AllConnector, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + U extends Router = Router, +> = (options: { + getConfig: GetConnectorConfig; + getCloudServiceClient?: GetCloudServiceClient; +}) => Promise; + +export type AllConnector = SmsConnector | EmailConnector | SocialConnector; + +export enum DemoConnector { + Sms = 'logto-sms', + Social = 'logto-social-demo', +} + +export const demoConnectorIds: readonly string[] = Object.freeze([ + DemoConnector.Sms, + DemoConnector.Social, +]); + +export enum ServiceConnector { + Email = 'logto-email', +} + +export const serviceConnectorIds: readonly string[] = Object.freeze([ServiceConnector.Email]); diff --git a/packages/toolkit/connector-kit/src/types/metadata.ts b/packages/toolkit/connector-kit/src/types/metadata.ts new file mode 100644 index 000000000..478e4b758 --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/metadata.ts @@ -0,0 +1,76 @@ +import type { LanguageTag } from '@logto/language-kit'; +import { isLanguageTag } from '@logto/language-kit'; +import type { ZodType } from 'zod'; +import { z } from 'zod'; + +import { connectorConfigFormItemGuard } from './config-form.js'; + +export enum ConnectorPlatform { + Native = 'Native', + Universal = 'Universal', + Web = 'Web', +} + +export const i18nPhrasesGuard: ZodType = z + .object({ en: z.string() }) + .and(z.record(z.string())) + .refine((i18nObject) => { + const keys = Object.keys(i18nObject); + + if (!keys.includes('en')) { + return false; + } + + for (const value of keys) { + if (!isLanguageTag(value)) { + return false; + } + } + + return true; + }); + +type I18nPhrases = { en: string } & { + [K in Exclude]?: string; +}; + +export const socialConnectorMetadataGuard = z.object({ + // Social connector platform. TODO: @darcyYe considering remove the nullable and make all the social connector field optional + platform: z.nativeEnum(ConnectorPlatform).nullable(), + // Indicates custom connector that follows standard protocol. Currently supported standard connectors are OIDC, OAuth2, and SAML2 + isStandard: z.boolean().optional(), +}); + +export const connectorMetadataGuard = z + .object({ + // Unique connector factory id + id: z.string(), + /* Connector provider. Unique for each provider. Users can have only one social identity per provider + For Social connectors, it's manually set on connector creation + For SSO connectors, it's the same as the issuer + */ + target: z.string(), + name: i18nPhrasesGuard, + description: i18nPhrasesGuard, + logo: z.string(), + logoDark: z.string().nullable(), + readme: z.string(), + configTemplate: z.string().optional(), // Connector config template + formItems: connectorConfigFormItemGuard.array().optional(), + }) + .merge(socialConnectorMetadataGuard) + .catchall(z.unknown()); + +export type ConnectorMetadata = z.infer; + +// Configurable connector metadata guard. Stored in DB metadata field +export const configurableConnectorMetadataGuard = connectorMetadataGuard + .pick({ + target: true, + name: true, + logo: true, + logoDark: true, + }) + .partial(); + +export type ConfigurableConnectorMetadata = z.infer; diff --git a/packages/toolkit/connector-kit/src/types/passwordless.ts b/packages/toolkit/connector-kit/src/types/passwordless.ts new file mode 100644 index 000000000..429312035 --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/passwordless.ts @@ -0,0 +1,74 @@ +// MARK: SMS + Email connector + +import { z } from 'zod'; + +import { type BaseConnector, type ConnectorType } from './foundation.js'; + +export enum VerificationCodeType { + SignIn = 'SignIn', + Register = 'Register', + ForgotPassword = 'ForgotPassword', + Generic = 'Generic', + /** @deprecated Use `Generic` type template for sending test sms/email use case */ + Test = 'Test', +} + +export const verificationCodeTypeGuard = z.nativeEnum(VerificationCodeType); + +export type SendMessagePayload = { + /** + * The dynamic verification code to send. + * @example '123456' + */ + code: string; +}; + +export const sendMessagePayloadGuard = z.object({ + code: z.string(), +}) satisfies z.ZodType; + +export const urlRegEx = + /(https?:\/\/)?(?:www\.)?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b[\w#%&()+./:=?@~-]*/; + +export const emailServiceBrandingGuard = z + .object({ + senderName: z + .string() + .refine((address) => !urlRegEx.test(address), 'DO NOT include URL in the sender name!'), + companyInformation: z + .string() + .refine( + (address) => !urlRegEx.test(address), + 'DO NOT include URL in the company information!' + ), + appLogo: z.string().url(), + }) + .partial(); + +export type EmailServiceBranding = z.infer; + +export type SendMessageData = { + to: string; + type: VerificationCodeType; + payload: SendMessagePayload; +}; + +export const sendMessageDataGuard = z.object({ + to: z.string(), + type: verificationCodeTypeGuard, + payload: sendMessagePayloadGuard, +}) satisfies z.ZodType; + +export type SendMessageFunction = (data: SendMessageData, config?: unknown) => Promise; + +export type GetUsageFunction = (startFrom?: Date) => Promise; + +export type SmsConnector = BaseConnector & { + sendMessage: SendMessageFunction; + getUsage?: GetUsageFunction; +}; + +export type EmailConnector = BaseConnector & { + sendMessage: SendMessageFunction; + getUsage?: GetUsageFunction; +}; diff --git a/packages/toolkit/connector-kit/src/types/social.ts b/packages/toolkit/connector-kit/src/types/social.ts new file mode 100644 index 000000000..865eb0318 --- /dev/null +++ b/packages/toolkit/connector-kit/src/types/social.ts @@ -0,0 +1,63 @@ +// MARK: Social connector +import { z } from 'zod'; + +import { type BaseConnector, type ConnectorType } from './foundation.js'; + +// This type definition is for SAML connector +export type ValidateSamlAssertion = ( + assertion: Record, + getSession: GetSession, + setSession: SetSession +) => Promise; + +export type GetAuthorizationUri = ( + payload: { + state: string; + redirectUri: string; + connectorId: string; + connectorFactoryId: string; + jti: string; + headers: { userAgent?: string }; + }, + setSession: SetSession +) => Promise; + +export const socialUserInfoGuard = z.object({ + id: z.string(), + email: z.string().optional(), + phone: z.string().optional(), + name: z.string().optional(), + avatar: z.string().optional(), +}); + +export type SocialUserInfo = z.infer; + +export type GetUserInfo = ( + data: unknown, + getSession: GetSession +) => Promise>; + +export const connectorSessionGuard = z + .object({ + nonce: z.string(), + redirectUri: z.string(), + connectorId: z.string(), + connectorFactoryId: z.string(), + jti: z.string(), + state: z.string(), + }) + .partial() + // Accept arbitrary unspecified keys so developers who can not publish @logto/connector-kit can more flexibly utilize connector session. + .catchall(z.unknown()); + +export type ConnectorSession = z.infer; + +export type GetSession = () => Promise; + +export type SetSession = (storage: ConnectorSession) => Promise; + +export type SocialConnector = BaseConnector & { + getAuthorizationUri: GetAuthorizationUri; + getUserInfo: GetUserInfo; + validateSamlAssertion?: ValidateSamlAssertion; +};