diff --git a/packages/core/src/queries/application.test.ts b/packages/core/src/queries/application.test.ts index 3cbbcd330..655d9ed3c 100644 --- a/packages/core/src/queries/application.test.ts +++ b/packages/core/src/queries/application.test.ts @@ -21,7 +21,6 @@ const pool = createMockPool({ const { createApplicationQueries } = await import('./application.js'); const { findTotalNumberOfApplications, - findAllApplications, findApplicationById, insertApplication, updateApplicationById, @@ -47,29 +46,6 @@ describe('application query', () => { await expect(findTotalNumberOfApplications()).resolves.toEqual({ count: 10 }); }); - it('findAllApplications', async () => { - const limit = 10; - const offset = 1; - const rowData = { id: 'foo' }; - - const expectSql = sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - order by "created_at" desc - limit $1 - offset $2 - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([limit, offset]); - - return createMockQueryResult([rowData]); - }); - - await expect(findAllApplications(limit, offset)).resolves.toEqual([rowData]); - }); - it('findApplicationById', async () => { const id = 'foo'; const rowData = { id }; diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 69bf8f44b..29e574118 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -5,7 +5,6 @@ import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logt import type { CommonQueryMethods, SqlSqlToken } from 'slonik'; import { sql } from 'slonik'; -import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { getTotalRowCountWithPool } from '#src/database/row-count.js'; @@ -49,12 +48,14 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { * * @param search The search config object, can apply to `id`, `name` and `description` field for application. * @param excludeApplicationIds Exclude applications with these ids. + * @param isThirdParty Optional boolean, filter applications by whether it is a third party application. * @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. * @returns A Promise that resolves the number of applications that match the search conditions. */ const countApplications = async ( search: Search, excludeApplicationIds: string[], + isThirdParty?: boolean, types?: ApplicationType[] ) => { const { count } = await pool.one<{ count: string }>(sql` @@ -65,6 +66,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` : sql``, types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, + isThirdParty ? sql`${fields.isThirdParty} = true` : sql`${fields.isThirdParty} = false`, buildApplicationConditions(search), ])} `); @@ -75,20 +77,32 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { /** * Get the list of applications that match the search conditions, conditions are joined in `and` mode. * - * @param search The search config object, can apply to `id`, `name` and `description` field for application - * @param excludeApplicationIds Exclude applications with these ids. - * @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. - * @param limit Limit of the number of applications in each page. - * @param offset Offset of the applications in the result. + * @param conditions The conditions to filter applications. + * @param conditions.search The search config object, can apply to `id`, `name` and `description` field for application + * @param conditions.excludeApplicationIds Exclude applications with these ids. + * @param conditions.types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. + * @param conditions.isThirdParty Optional boolean, filter applications by whether it is a third party application. + * @param conditions.pagination Optional pagination config object. + * @param conditions.pagination.limit The number of applications to return. + * @param conditions.pagination.offset The offset of applications to return. * @returns A Promise that resolves the list of applications that match the search conditions. */ - const findApplications = async ( - search: Search, - excludeApplicationIds: string[], - types?: ApplicationType[], - limit?: number, - offset?: number - ) => + const findApplications = async ({ + search, + excludeApplicationIds, + types, + isThirdParty, + pagination, + }: { + search: Search; + excludeApplicationIds: string[]; + types?: ApplicationType[]; + isThirdParty?: boolean; + pagination?: { + limit: number; + offset: number; + }; + }) => pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} @@ -97,19 +111,16 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` : sql``, types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, + isThirdParty ? sql`${fields.isThirdParty} = true` : sql`${fields.isThirdParty} = false`, buildApplicationConditions(search), ])} order by ${fields.createdAt} desc - ${conditionalSql(limit, (value) => sql`limit ${value}`)} - ${conditionalSql(offset, (value) => sql`offset ${value}`)} + ${conditionalSql(pagination?.limit, (value) => sql`limit ${value}`)} + ${conditionalSql(pagination?.offset, (value) => sql`offset ${value}`)} `); const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table); - const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [ - { field: 'createdAt', order: 'desc' }, - ]); - const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications); const insertApplication = buildInsertIntoWithPool(pool)(Applications, { @@ -212,7 +223,6 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { countApplications, findApplications, findTotalNumberOfApplications, - findAllApplications, findApplicationById, insertApplication, updateApplication, diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 2d207b4ea..c5394992a 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -7,8 +7,10 @@ import { applicationPatchGuard, } from '@logto/schemas'; import { generateStandardId, generateStandardSecret } from '@logto/shared'; +import { conditional } from '@silverhand/essentials'; import { boolean, object, string, z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -64,6 +66,11 @@ export default function applicationRoutes( .array() .or(applicationTypeGuard.transform((type) => [type])) .optional(), + excludeRoleId: string().optional(), + // FIXME: @simeng-li Remove this guard once Logto as IdP is ready + ...conditional( + EnvSet.values.isDevFeaturesEnabled && { isThirdParty: z.literal('true').optional() } + ), }), response: z.array(applicationResponseGuard), status: 200, @@ -71,27 +78,37 @@ export default function applicationRoutes( async (ctx, next) => { const { limit, offset, disabled: paginationDisabled } = ctx.pagination; const { searchParams } = ctx.URL; - const { types } = ctx.guard.query; + const { types, excludeRoleId } = ctx.guard.query; + // FIXME: @simeng-li Remove this guard once Logto as IdP is ready + const isThirdParty = Boolean(ctx.guard.query.isThirdParty); + + // This will only parse the `search` query param, other params will be ignored. Please use query guard to validate them. const search = parseSearchParamsForSearch(searchParams); - const excludeRoleId = searchParams.get('excludeRoleId'); const excludeApplicationsRoles = excludeRoleId ? await findApplicationsRolesByRoleId(excludeRoleId) : []; + const excludeApplicationIds = excludeApplicationsRoles.map( ({ applicationId }) => applicationId ); if (paginationDisabled) { - ctx.body = await findApplications(search, excludeApplicationIds, types); + ctx.body = await findApplications({ search, excludeApplicationIds, types, isThirdParty }); return next(); } const [{ count }, applications] = await Promise.all([ - countApplications(search, excludeApplicationIds, types), - findApplications(search, excludeApplicationIds, types, limit, offset), + countApplications(search, excludeApplicationIds, isThirdParty, types), + findApplications({ + search, + excludeApplicationIds, + types, + isThirdParty, + pagination: { limit, offset }, + }), ]); // Return totalCount to pagination middleware @@ -118,6 +135,14 @@ export default function applicationRoutes( : 'applicationsLimit' ); + // Third party applications must be traditional type + if (rest.isThirdParty) { + assertThat( + rest.type === ApplicationType.Traditional, + 'application.invalid_third_party_application_type' + ); + } + ctx.body = await insertApplication({ id: generateStandardId(), secret: generateStandardSecret(), diff --git a/packages/core/src/routes/applications/types.ts b/packages/core/src/routes/applications/types.ts index 03e29ad39..b4dfcd17f 100644 --- a/packages/core/src/routes/applications/types.ts +++ b/packages/core/src/routes/applications/types.ts @@ -6,10 +6,14 @@ import { import { EnvSet } from '#src/env-set/index.js'; // FIXME: @simeng-li Remove this guard once Logto as IdP is ready -export const applicationResponseGuard = EnvSet.values.isDevFeaturesEnabled +// @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler +export const applicationResponseGuard: typeof Applications.guard = EnvSet.values + .isDevFeaturesEnabled ? Applications.guard : Applications.guard.omit({ isThirdParty: true }); -export const applicationCreateGuard = EnvSet.values.isDevFeaturesEnabled +// @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler +export const applicationCreateGuard: typeof originalApplicationCreateGuard = EnvSet.values + .isDevFeaturesEnabled ? originalApplicationCreateGuard : originalApplicationCreateGuard.omit({ isThirdParty: true }); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index 5ab9d760b..4361c0a66 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -26,7 +26,8 @@ export const createApplication = async ( export const getApplications = async ( types?: ApplicationType[], - searchParameters?: Record + searchParameters?: Record, + isThirdParty?: boolean ) => { const searchParams = new URLSearchParams([ ...(conditional(types && types.length > 0 && types.map((type) => ['types', type])) ?? []), @@ -35,6 +36,7 @@ export const getApplications = async ( Object.keys(searchParameters).length > 0 && Object.entries(searchParameters).map(([key, value]) => [key, value]) ) ?? []), + ...(conditional(isThirdParty && [['isThirdParty', 'true']]) ?? []), ]); return authedAdminApi.get('applications', { searchParams }).json(); diff --git a/packages/integration-tests/src/tests/api/application.test.ts b/packages/integration-tests/src/tests/api/application.test.ts index 3420e203b..aa2d97cf4 100644 --- a/packages/integration-tests/src/tests/api/application.test.ts +++ b/packages/integration-tests/src/tests/api/application.test.ts @@ -8,6 +8,7 @@ import { deleteApplication, getApplications, } from '#src/api/index.js'; +import { expectRejects } from '#src/helpers/index.js'; describe('admin console application', () => { it('should create application successfully', async () => { @@ -17,6 +18,7 @@ describe('admin console application', () => { expect(application.name).toBe(applicationName); expect(application.type).toBe(applicationType); + expect(application.isThirdParty).toBe(false); const fetchedApplication = await getApplication(application.id); @@ -24,6 +26,28 @@ describe('admin console application', () => { expect(fetchedApplication.id).toBe(application.id); }); + it('should throw error when creating a third party application with invalid type', async () => { + await expectRejects( + createApplication('test-create-app', ApplicationType.Native, { + isThirdParty: true, + }), + { code: 'application.invalid_third_party_application_type', statusCode: 400 } + ); + }); + + it('should create third party application successfully', async () => { + const applicationName = 'test-third-party-app'; + + const application = await createApplication(applicationName, ApplicationType.Traditional, { + isThirdParty: true, + }); + + expect(application.name).toBe(applicationName); + expect(application.type).toBe(ApplicationType.Traditional); + expect(application.isThirdParty).toBe(true); + await deleteApplication(application.id); + }); + it('should update application details successfully', async () => { const application = await createApplication('test-update-app', ApplicationType.Traditional); @@ -74,11 +98,30 @@ describe('admin console application', () => { expect(application.id).toBe('demo-app'); }); - it('should fetch all applications created above', async () => { + it('should fetch all non-third party applications created above', async () => { const applications = await getApplications(); + const applicationNames = applications.map(({ name }) => name); expect(applicationNames).toContain('test-create-app'); expect(applicationNames).toContain('test-update-app'); + + expect(applications.some(({ isThirdParty }) => isThirdParty)).toBe(false); + }); + + it('should fetch all third party applications created', async () => { + const application = await createApplication( + 'test-third-party-app', + ApplicationType.Traditional, + { + isThirdParty: true, + } + ); + + const applications = await getApplications(undefined, undefined, true); + + expect(applications.find(({ id }) => id === application.id)).toEqual(application); + expect(applications.some(({ isThirdParty }) => !isThirdParty)).toBe(false); + await deleteApplication(application.id); }); it('should create m2m application successfully and can get only m2m applications by specifying types', async () => { diff --git a/packages/phrases/src/locales/de/errors/application.ts b/packages/phrases/src/locales/de/errors/application.ts index 4175564f3..8cd5852f7 100644 --- a/packages/phrases/src/locales/de/errors/application.ts +++ b/packages/phrases/src/locales/de/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'Die Rolle mit der ID {{roleId}} wurde bereits dieser Anwendung hinzugefügt.', invalid_role_type: 'Es ist nicht möglich, einer Maschinen-zu-Maschinen-Anwendung eine Benutzertyp-Rolle zuzuweisen.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 595b4fb1b..0ae69535f 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -2,6 +2,8 @@ const application = { invalid_type: 'Only machine to machine applications can have associated roles.', role_exists: 'The role id {{roleId}} is already been added to this application.', invalid_role_type: 'Can not assign user type role to machine to machine application.', + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/es/errors/application.ts b/packages/phrases/src/locales/es/errors/application.ts index c0775811f..12cd0d93d 100644 --- a/packages/phrases/src/locales/es/errors/application.ts +++ b/packages/phrases/src/locales/es/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'La identificación del rol {{roleId}} ya se ha agregado a esta aplicación.', invalid_role_type: 'No se puede asignar un rol de tipo usuario a una aplicación de máquina a máquina.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/fr/errors/application.ts b/packages/phrases/src/locales/fr/errors/application.ts index 672b38608..d5eda1dc4 100644 --- a/packages/phrases/src/locales/fr/errors/application.ts +++ b/packages/phrases/src/locales/fr/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: "Le rôle d'identifiant {{roleId}} a déjà été ajouté à cette application.", invalid_role_type: "Impossible d'assigner un rôle de type utilisateur à une application machine à machine.", + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/it/errors/application.ts b/packages/phrases/src/locales/it/errors/application.ts index f3a7ce494..e82fcd7ff 100644 --- a/packages/phrases/src/locales/it/errors/application.ts +++ b/packages/phrases/src/locales/it/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: "L'ID ruolo {{roleId}} è già stato aggiunto a questa applicazione.", invalid_role_type: "Impossibile assegnare un ruolo di tipo utente all'applicazione da macchina a macchina.", + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ja/errors/application.ts b/packages/phrases/src/locales/ja/errors/application.ts index 884a2e736..9403c5d3b 100644 --- a/packages/phrases/src/locales/ja/errors/application.ts +++ b/packages/phrases/src/locales/ja/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'ロールID {{roleId}} は、すでにこのアプリケーションに追加されています。', invalid_role_type: 'ユーザータイプのロールをマシン間アプリケーションに割り当てることはできません。', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ko/errors/application.ts b/packages/phrases/src/locales/ko/errors/application.ts index 0649a9fda..7dc80c1e8 100644 --- a/packages/phrases/src/locales/ko/errors/application.ts +++ b/packages/phrases/src/locales/ko/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: '관련 역할을 가질 수 있는 것은 기계 대 기계 응용 프로그램만 가능합니다.', role_exists: '역할 ID {{roleId}} 가 이미이 응용 프로그램에 추가되었습니다.', invalid_role_type: '사용자 유형 역할을 기계 대 기계 응용 프로그램에 할당할 수 없습니다.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pl-pl/errors/application.ts b/packages/phrases/src/locales/pl-pl/errors/application.ts index 3e82d1a7a..97bd43fdd 100644 --- a/packages/phrases/src/locales/pl-pl/errors/application.ts +++ b/packages/phrases/src/locales/pl-pl/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: 'Tylko aplikacje maszyna-do-maszyny mogą mieć przypisane role.', role_exists: 'Rola o identyfikatorze {{roleId}} została już dodana do tej aplikacji.', invalid_role_type: 'Nie można przypisać roli typu użytkownika do aplikacji maszyna-do-maszyny.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pt-br/errors/application.ts b/packages/phrases/src/locales/pt-br/errors/application.ts index 365e3fcf1..764cd951c 100644 --- a/packages/phrases/src/locales/pt-br/errors/application.ts +++ b/packages/phrases/src/locales/pt-br/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'O id da função {{roleId}} já foi adicionado a este aplicativo.', invalid_role_type: 'Não é possível atribuir uma função de tipo de usuário a um aplicativo de máquina para máquina.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/pt-pt/errors/application.ts b/packages/phrases/src/locales/pt-pt/errors/application.ts index cdff351a6..fea3624ab 100644 --- a/packages/phrases/src/locales/pt-pt/errors/application.ts +++ b/packages/phrases/src/locales/pt-pt/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'O id da função {{roleId}} já foi adicionado a esta aplicação.', invalid_role_type: 'Não é possível atribuir uma função de tipo de utilizador a uma aplicação máquina a máquina.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/ru/errors/application.ts b/packages/phrases/src/locales/ru/errors/application.ts index 3178c0e75..42639d970 100644 --- a/packages/phrases/src/locales/ru/errors/application.ts +++ b/packages/phrases/src/locales/ru/errors/application.ts @@ -3,6 +3,9 @@ const application = { role_exists: 'Роль с идентификатором {{roleId}} уже добавлена в это приложение.', invalid_role_type: 'Невозможно назначить роль типа "пользователь" для приложения типа "от машины к машине".', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/tr-tr/errors/application.ts b/packages/phrases/src/locales/tr-tr/errors/application.ts index 2322812e1..4084dcdae 100644 --- a/packages/phrases/src/locales/tr-tr/errors/application.ts +++ b/packages/phrases/src/locales/tr-tr/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: 'Sadece makine ile makine uygulamaları rollerle ilişkilendirilebilir.', role_exists: 'Bu uygulamaya zaten {{roleId}} kimlikli bir rol eklenmiş.', invalid_role_type: 'Kullanıcı tipi rolü makine ile makine uygulamasına atayamaz.', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-cn/errors/application.ts b/packages/phrases/src/locales/zh-cn/errors/application.ts index 77c502079..b57469102 100644 --- a/packages/phrases/src/locales/zh-cn/errors/application.ts +++ b/packages/phrases/src/locales/zh-cn/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: '只有机器对机器应用程序可以有关联角色。', role_exists: '角色 ID {{roleId}} 已添加到此应用程序。', invalid_role_type: '无法将用户类型角色分配给机器对机器应用程序。', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-hk/errors/application.ts b/packages/phrases/src/locales/zh-hk/errors/application.ts index e087c636a..636b32293 100644 --- a/packages/phrases/src/locales/zh-hk/errors/application.ts +++ b/packages/phrases/src/locales/zh-hk/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: '只有機器對機器應用程式才能有相關職能。', role_exists: '角色 ID {{roleId}} 已經被添加到此應用程式中。', invalid_role_type: '無法將使用者類型的角色分配給機器對機器應用程式。', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application); diff --git a/packages/phrases/src/locales/zh-tw/errors/application.ts b/packages/phrases/src/locales/zh-tw/errors/application.ts index 34887d65c..da89e6b32 100644 --- a/packages/phrases/src/locales/zh-tw/errors/application.ts +++ b/packages/phrases/src/locales/zh-tw/errors/application.ts @@ -2,6 +2,9 @@ const application = { invalid_type: '僅允許機器對機器應用程式附加角色。', role_exists: '該角色 ID {{roleId}} 已被添加至此應用程式。', invalid_role_type: '無法將使用者類型的角色指派給機器對機器應用程式。', + /** UNTRANSLATED */ + invalid_third_party_application_type: + 'Only traditional web applications can be marked as a third-party app.', }; export default Object.freeze(application);