From aba089285bca25d2507ff97e5e162cdc58cedc06 Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Wed, 28 Aug 2024 07:00:24 +0200 Subject: [PATCH] feat: add Patreon connector (#6514) --- .changeset/selfish-kangaroos-perform.md | 18 ++ .../connectors/connector-patreon/README.md | 60 ++++++ .../connector-patreon/logo-dark.svg | 11 ++ .../connectors/connector-patreon/logo.svg | 8 + .../connectors/connector-patreon/package.json | 69 +++++++ .../connector-patreon/src/constant.ts | 37 ++++ .../connector-patreon/src/index.test.ts | 155 +++++++++++++++ .../connectors/connector-patreon/src/index.ts | 178 ++++++++++++++++++ .../connectors/connector-patreon/src/mock.ts | 4 + .../connectors/connector-patreon/src/types.ts | 46 +++++ pnpm-lock.yaml | 58 ++++++ 11 files changed, 644 insertions(+) create mode 100644 .changeset/selfish-kangaroos-perform.md create mode 100644 packages/connectors/connector-patreon/README.md create mode 100644 packages/connectors/connector-patreon/logo-dark.svg create mode 100644 packages/connectors/connector-patreon/logo.svg create mode 100644 packages/connectors/connector-patreon/package.json create mode 100644 packages/connectors/connector-patreon/src/constant.ts create mode 100644 packages/connectors/connector-patreon/src/index.test.ts create mode 100644 packages/connectors/connector-patreon/src/index.ts create mode 100644 packages/connectors/connector-patreon/src/mock.ts create mode 100644 packages/connectors/connector-patreon/src/types.ts diff --git a/.changeset/selfish-kangaroos-perform.md b/.changeset/selfish-kangaroos-perform.md new file mode 100644 index 000000000..d9bb056b4 --- /dev/null +++ b/.changeset/selfish-kangaroos-perform.md @@ -0,0 +1,18 @@ +--- +"@logto/connector-patreon": major +--- + +add Patreon social connector + +### Major Changes + +- Initial release of the Patreon connector. + + This release introduces the Logto connector for Patreon, enabling social sign-in using Patreon accounts. It supports OAuth 2.0 authentication flow, fetching user information, and handling errors gracefully. + +### Features + +- **OAuth 2.0 Authentication**: Support for OAuth 2.0 authentication flow with Patreon. +- **User Information Retrieval**: Fetches user details such as full name, email, profile URL, and avatar. +- **Error Handling**: Graceful handling of OAuth errors, including token exchange failures and user-denied permissions. +- **Configurable Scope**: Allows customization of OAuth scopes to access different levels of user information. diff --git a/packages/connectors/connector-patreon/README.md b/packages/connectors/connector-patreon/README.md new file mode 100644 index 000000000..d192bbb1e --- /dev/null +++ b/packages/connectors/connector-patreon/README.md @@ -0,0 +1,60 @@ +# Patreon Connector + +The official Logto connector for Patreon social sign-in, based on the Hugging Face connector by Silverhand Inc. + +**Table of contents** + +- [Patreon connector](#patreon-connector) + - [Get started](#get-started) + - [Sign in with Patreon account](#sign-in-with-patreon-account) + - [Create and configure OAuth app](#create-and-configure-oauth-app) + - [Managing OAuth apps](#managing-oauth-apps) + - [Configure your connector](#configure-your-connector) + - [Config types](#config-types) + - [Test Patreon connector](#test-patreon-connector) + - [Reference](#reference) + +## Get started + +The Patreon connector enables end-users to sign in to your application using their own Patreon accounts via the Patreon OAuth 2.0 authentication protocol. This connector is adapted from the Hugging Face connector by Silverhand Inc., leveraging many of the same implementation patterns and configurations. + +## Sign in with Patreon account + +Go to the [Patreon website](https://www.patreon.com/) and sign in with your Patreon account. You may register a new account if you don't have one. + +## Create and configure OAuth app + +Follow the [creating a Patreon OAuth App](https://www.patreon.com/portal/registration/register-clients) guide, and register a new application. + +Name your new OAuth application in **App Name** and fill up **App URL** of the app. You can leave the **App Description** field blank and customize the **Redirect URIs** as `${your_logto_origin}/callback/${connector_id}`. The `connector_id` can be found on the top bar of the Logto Admin Console connector details page. + +> Note: If you encounter the error message "The redirect_uri MUST match the registered callback URL for this application." when logging in, try aligning the Redirect URI of your Patreon OAuth App and your Logto App's redirect URL (including the protocol) to resolve the issue. + +## Managing OAuth apps + +Go to the [Clients & API Keys page](https://www.patreon.com/portal/registration/register-clients) on Patreon, where you can add, edit, or delete existing OAuth apps. You can also find the `Client ID` and generate `Client secrets` in the OAuth app detail pages. + +## Configure your connector + +Fill out the `clientId` and `clientSecret` field with the _Client ID_ and _Client Secret_ you've got from the OAuth app detail pages mentioned in the previous section. + +`scope` is a space-delimited list of [scopes](https://docs.patreon.com/#scopes). If not provided, the scope defaults to `identity identity[email]`. + +### Config types + +| Name | Type | +|--------------|--------| +| clientId | string | +| clientSecret | string | +| scope | string | + +## Test Patreon connector + +That's it. The Patreon connector should be available now. Don't forget to [Enable connector in sign-in experience](https://docs.logto.io/docs/recipes/configure-connectors/social-connector/enable-social-sign-in/). + +## Reference + +- [Patreon - API Documentation](https://docs.patreon.com/) +- [Patreon - Developers - Clients](https://www.patreon.com/portal/registration/register-clients) + +--- \ No newline at end of file diff --git a/packages/connectors/connector-patreon/logo-dark.svg b/packages/connectors/connector-patreon/logo-dark.svg new file mode 100644 index 000000000..f5d299709 --- /dev/null +++ b/packages/connectors/connector-patreon/logo-dark.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/packages/connectors/connector-patreon/logo.svg b/packages/connectors/connector-patreon/logo.svg new file mode 100644 index 000000000..4cf79b604 --- /dev/null +++ b/packages/connectors/connector-patreon/logo.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/connectors/connector-patreon/package.json b/packages/connectors/connector-patreon/package.json new file mode 100644 index 000000000..1ca75827b --- /dev/null +++ b/packages/connectors/connector-patreon/package.json @@ -0,0 +1,69 @@ +{ + "name": "@logto/connector-patreon", + "version": "0.0.1", + "description": "Patreon web connector implementation.", + "author": "DevTekVE & Silverhand Inc. ", + "dependencies": { + "@logto/connector-kit": "workspace:^4.0.0", + "@logto/connector-oauth": "workspace:^1.4.0", + "@silverhand/essentials": "^2.9.1", + "ky": "^1.2.3", + "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": "14.0.0-beta.9", + "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-patreon/src/constant.ts b/packages/connectors/connector-patreon/src/constant.ts new file mode 100644 index 000000000..d0c10c6af --- /dev/null +++ b/packages/connectors/connector-patreon/src/constant.ts @@ -0,0 +1,37 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorPlatform } from '@logto/connector-kit'; +import { clientIdFormItem, clientSecretFormItem, scopeFormItem } from '@logto/connector-oauth'; + +export const authorizationEndpoint = 'https://www.patreon.com/oauth2/authorize'; +export const scope = 'identity identity[email]'; +export const tokenEndpoint = 'https://www.patreon.com/api/oauth2/token'; +export const userInfoEndpoint = 'https://www.patreon.com/api/oauth2/api/current_user'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'patreon-universal', + target: 'patreon', + platform: ConnectorPlatform.Universal, + name: { + en: 'Patreon', + 'zh-CN': 'Patreon', + 'tr-TR': 'Patreon', + ko: 'Patreon', + }, + logo: './logo.svg', + logoDark: './logo-dark.svg', + description: { + en: 'Patreon is a membership platform that makes it easy for artists and creators to get paid.', + }, + readme: './README.md', + formItems: [ + clientIdFormItem, + clientSecretFormItem, + { + ...scopeFormItem, + description: + "The `scope` determines permissions granted by the user's authorization. If you are not sure what to enter, do not worry, just leave it blank.", + }, + ], +}; + +export const defaultTimeout = 5000; diff --git a/packages/connectors/connector-patreon/src/index.test.ts b/packages/connectors/connector-patreon/src/index.test.ts new file mode 100644 index 000000000..ec927b74a --- /dev/null +++ b/packages/connectors/connector-patreon/src/index.test.ts @@ -0,0 +1,155 @@ +import nock from 'nock'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; + +import { authorizationEndpoint, tokenEndpoint, userInfoEndpoint } from './constant.js'; +import createConnector from './index.js'; + +const getConfig = vi.fn().mockResolvedValue({ + clientId: '', + clientSecret: '', + scope: 'profile email', +}); + +const getSessionMock = vi.fn().mockResolvedValue({ redirectUri: 'http://localhost:3000/callback' }); + +describe('Patreon connector', () => { + beforeEach(() => { + nock(tokenEndpoint).post('').reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); + }); + + afterEach(() => { + nock.cleanAll(); + vi.clearAllMocks(); + }); + + it('should get a valid uri by redirectUri and state', async () => { + const connector = await createConnector({ getConfig }); + const authorizationUri = await connector.getAuthorizationUri( + { + state: 'some_state', + redirectUri: 'http://localhost:3000/callback', + connectorId: 'some_connector_id', + connectorFactoryId: 'some_connector_factory_id', + jti: 'some_jti', + headers: {}, + }, + vi.fn() + ); + + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: '', + scope: 'profile email', + redirect_uri: 'http://localhost:3000/callback', + state: 'some_state', + }).toString()}` + ); + }); + + it('should get valid SocialUserInfo', async () => { + nock(userInfoEndpoint) + .get('') + .reply(200, { + data: { + id: '12345', + attributes: { + full_name: 'Jane Doe', + vanity: 'janedoe', + url: 'https://www.patreon.com/janedoe', + image_url: 'https://c10.patreon.com/2/400/12345', + email: 'janedoe@example.com', + is_email_verified: true, + created: '2020-01-01T12:00:00Z', + }, + }, + }); + + const connector = await createConnector({ getConfig }); + const socialUserInfo = await connector.getUserInfo({ code: 'code' }, getSessionMock); + + expect(socialUserInfo).toStrictEqual({ + id: '12345', + avatar: 'https://c10.patreon.com/2/400/12345', + name: 'Jane Doe', + email: 'janedoe@example.com', + email_verified: true, + profile: 'https://www.patreon.com/janedoe', + preferred_username: 'janedoe', + website: 'https://www.patreon.com/janedoe', + rawData: { + data: { + id: '12345', + attributes: { + full_name: 'Jane Doe', + vanity: 'janedoe', + url: 'https://www.patreon.com/janedoe', + image_url: 'https://c10.patreon.com/2/400/12345', + email: 'janedoe@example.com', + is_email_verified: true, + created: '2020-01-01T12:00:00Z', + }, + }, + }, + }); + }); + + it('throws AuthorizationFailed error if authentication failed', async () => { + const connector = await createConnector({ getConfig }); + await expect( + connector.getUserInfo({ error: 'some error' }, getSessionMock) + ).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, { error: 'some error' }) + ); + }); + + it('throws InvalidResponse error if token response is invalid', async () => { + // Clear token response mock + nock.cleanAll(); + + nock(tokenEndpoint).post('').reply(200, { + invalid_field: true, + }); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy( + (connectorError) => + (connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse + ); + }); + + it('throws InvalidResponse error if userinfo response is invalid', async () => { + nock(userInfoEndpoint).get('').reply(200, { + id: 'id', + }); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy( + (connectorError) => + (connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse + ); + }); + + it('throws SocialAccessTokenInvalid error if user info responded with 401', async () => { + nock(userInfoEndpoint).get('').reply(401); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws General error if user info responded with a non-401 error', async () => { + nock(userInfoEndpoint).get('').reply(422); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.General) + ); + }); +}); diff --git a/packages/connectors/connector-patreon/src/index.ts b/packages/connectors/connector-patreon/src/index.ts new file mode 100644 index 000000000..249682b40 --- /dev/null +++ b/packages/connectors/connector-patreon/src/index.ts @@ -0,0 +1,178 @@ +import { assert, conditional } from '@silverhand/essentials'; + +import type { + GetAuthorizationUri, + GetUserInfo, + SocialConnector, + CreateConnector, + GetConnectorConfig, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + parseJson, +} from '@logto/connector-kit'; +import { + constructAuthorizationUri, + oauth2AuthResponseGuard, + requestTokenEndpoint, + TokenEndpointAuthMethod, +} from '@logto/connector-oauth'; +import ky, { HTTPError } from 'ky'; + +import { + authorizationEndpoint, + scope as defaultScope, + userInfoEndpoint, + defaultMetadata, + defaultTimeout, + tokenEndpoint, +} from './constant.js'; +import { accessTokenResponseGuard, patreonConfigGuard, userInfoResponseGuard } from './types.js'; + +/** + * Generates an authorization URI for the given configuration. + * + * @param {GetConnectorConfig} getConfig - Function to retrieve the connector configuration. + * @return {GetAuthorizationUri} - A function that generates the authorization URI. + */ +const getAuthorizationUri = + (getConfig: GetConnectorConfig): GetAuthorizationUri => + async ({ state, redirectUri }, setSession) => { + const config = await getConfig(defaultMetadata.id); + validateConfig(config, patreonConfigGuard); + + const { clientId, scope } = config; + + await setSession({ redirectUri }); + + return constructAuthorizationUri(authorizationEndpoint, { + responseType: 'code', + clientId, + scope: scope ?? defaultScope, // Defaults to 'profile' if not provided + redirectUri, + state, + }); + }; + +/** + * Asynchronously fetches user information from a social media provider. + * + * @param {GetConnectorConfig} getConfig - A function to get the connector configuration. + * + * @returns {GetUserInfo} - A function that retrieves user information. + * + * @throws {ConnectorError} - If an error occurs during the retrieval of user information. + */ +const getUserInfo = + (getConfig: GetConnectorConfig): GetUserInfo => + async (data, getSession) => { + const authResponseResult = oauth2AuthResponseGuard.safeParse(data); + + if (!authResponseResult.success) { + throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, data); + } + + const { code } = authResponseResult.data; + + const config = await getConfig(defaultMetadata.id); + validateConfig(config, patreonConfigGuard); + + const { clientId, clientSecret } = config; + const { redirectUri } = await getSession(); + + if (!redirectUri) { + throw new ConnectorError(ConnectorErrorCodes.General, { + message: 'Cannot find `redirectUri` from connector session.', + }); + } + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretBasic, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code, + redirectUri, + clientId, + clientSecret, + }, + }); + + const parsedTokenResponse = accessTokenResponseGuard.safeParse(await tokenResponse.json()); + + if (!parsedTokenResponse.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedTokenResponse.error); + } + + const { access_token: accessToken, token_type: tokenType } = parsedTokenResponse.data; + + assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); + + try { + const userInfoResponse = await ky.get(userInfoEndpoint, { + headers: { + authorization: `${tokenType} ${accessToken}`, + }, + timeout: defaultTimeout, + }); + + const rawData = parseJson(await userInfoResponse.text()); + + const parsedUserInfoResponse = userInfoResponseGuard.safeParse(rawData); + + if (!parsedUserInfoResponse.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedUserInfoResponse.error); + } + + const { data } = parsedUserInfoResponse.data; + const { attributes } = data; + + return { + id: data.id, + avatar: conditional(attributes.image_url), + email: conditional(attributes.email), + name: conditional(attributes.full_name), + rawData, + email_verified: conditional(attributes.is_email_verified), // Direct email verification info + profile: attributes.url, + preferred_username: attributes.vanity, + website: attributes.url, + }; + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { response } = error; + + if (response.status === 401) { + throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); + } + + throw new ConnectorError(ConnectorErrorCodes.General, await response.text()); + } + + throw error; + } + }; + +/** + * Creates a Patreon connector. + * + * @param {Object} options - Options for creating the connector. + * @param {Function} options.getConfig - Function used to get configuration options. + * @returns {Object} - Patreon connector object. + */ +const createPatreonConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Social, + configGuard: patreonConfigGuard, + getAuthorizationUri: getAuthorizationUri(getConfig), + getUserInfo: getUserInfo(getConfig), + }; +}; + +export default createPatreonConnector; diff --git a/packages/connectors/connector-patreon/src/mock.ts b/packages/connectors/connector-patreon/src/mock.ts new file mode 100644 index 000000000..a52d77ccc --- /dev/null +++ b/packages/connectors/connector-patreon/src/mock.ts @@ -0,0 +1,4 @@ +export const mockedConfig = { + clientId: '', + clientSecret: '', +}; diff --git a/packages/connectors/connector-patreon/src/types.ts b/packages/connectors/connector-patreon/src/types.ts new file mode 100644 index 000000000..7df434f00 --- /dev/null +++ b/packages/connectors/connector-patreon/src/types.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +// Define a configuration guard for Patreon +export const patreonConfigGuard = z.object({ + clientId: z.string(), + clientSecret: z.string(), + scope: z.string().optional(), +}); + +export type PatreonConfig = z.infer; + +// Define a guard for validating the access token response +export const accessTokenResponseGuard = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), + scope: z.string(), +}); + +export type AccessTokenResponse = z.infer; + +// Define a guard for validating the Patreon user info response +export const userInfoResponseGuard = z.object({ + data: z.object({ + id: z.string(), + attributes: z.object({ + full_name: z.string().optional().nullable(), + vanity: z.string().optional().nullable(), + url: z.string().optional().nullable(), + image_url: z.string().optional().nullable(), + email: z.string().optional().nullable(), + is_email_verified: z.boolean().optional(), + }), + }), +}); + +export type PatreonUserInfoResponse = z.infer; + +export const authorizationCallbackErrorGuard = z.object({ + error: z.string(), + error_description: z.string(), + error_uri: z.string(), +}); + +export const authResponseGuard = z.object({ code: z.string() }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7af05f0fb..1e541085e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1842,6 +1842,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-patreon: + dependencies: + '@logto/connector-kit': + specifier: workspace:^4.0.0 + version: link:../../toolkit/connector-kit + '@logto/connector-oauth': + specifier: workspace:^1.4.0 + version: link:../connector-oauth2 + '@silverhand/essentials': + specifier: ^2.9.1 + version: 2.9.1 + ky: + specifier: ^1.2.3 + version: 1.2.3 + 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: 14.0.0-beta.9 + version: 14.0.0-beta.9 + 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-postmark: dependencies: '@logto/connector-kit':