diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 8ab72e518..7d0af8aba 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -51,8 +51,8 @@ export const mockProtectedApplication: Application = { type: ApplicationType.Protected, description: null, oidcClientMetadata: { - redirectUris: [], - postLogoutRedirectUris: [], + redirectUris: ['https://mock.protected.dev/callback'], + postLogoutRedirectUris: ['https://mock.protected.dev'], }, customClientMetadata: { corsAllowedOrigins: ['http://localhost:3000', 'http://localhost:3001', 'https://logto.dev'], diff --git a/packages/core/src/libraries/protected-app.test.ts b/packages/core/src/libraries/protected-app.test.ts index 8356871f4..f102b4607 100644 --- a/packages/core/src/libraries/protected-app.test.ts +++ b/packages/core/src/libraries/protected-app.test.ts @@ -1,6 +1,11 @@ import { createMockUtils } from '@logto/shared/esm'; import { mockProtectedApplication } from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { + defaultProtectedAppPageRules, + defaultProtectedAppSessionDuration, +} from '#src/routes/applications/constants.js'; import SystemContext from '#src/tenants/SystemContext.js'; const { jest } = import.meta; @@ -17,8 +22,9 @@ const { MockQueries } = await import('#src/test-utils/tenant.js'); const { createProtectedAppLibrary } = await import('./protected-app.js'); const findApplicationById = jest.fn(async () => mockProtectedApplication); -const { syncAppConfigsToRemote } = createProtectedAppLibrary( - new MockQueries({ applications: { findApplicationById } }) +const findApplicationByProtectedAppHost = jest.fn(); +const { syncAppConfigsToRemote, checkAndBuildProtectedAppData } = createProtectedAppLibrary( + new MockQueries({ applications: { findApplicationById, findApplicationByProtectedAppHost } }) ); const protectedAppConfigProviderConfig = { @@ -26,6 +32,7 @@ const protectedAppConfigProviderConfig = { namespaceIdentifier: 'fake_namespace_id', keyName: 'fake_key_name', apiToken: '', + domain: 'protected.app', }; beforeAll(() => { @@ -72,3 +79,43 @@ describe('syncAppConfigsToRemote()', () => { ); }); }); + +describe('checkAndBuildProtectedAppData()', () => { + const origin = 'https://example.com'; + + it('should throw if subdomain is invalid', async () => { + await expect(checkAndBuildProtectedAppData({ subDomain: 'a-', origin })).rejects.toThrowError( + new RequestError({ + code: 'application.invalid_subdomain', + status: 422, + }) + ); + }); + + it('should throw if subdomain is not available', async () => { + findApplicationByProtectedAppHost.mockResolvedValueOnce(mockProtectedApplication); + await expect(checkAndBuildProtectedAppData({ subDomain: 'a', origin })).rejects.toThrowError( + new RequestError({ + code: 'application.protected_application_subdomain_exists', + status: 422, + }) + ); + }); + + it('should return data if subdomain is available', async () => { + const subDomain = 'a'; + const host = `${subDomain}.${protectedAppConfigProviderConfig.domain}`; + await expect(checkAndBuildProtectedAppData({ subDomain, origin })).resolves.toEqual({ + protectedAppMetadata: { + host, + origin, + sessionDuration: defaultProtectedAppSessionDuration, + pageRules: defaultProtectedAppPageRules, + }, + oidcClientMetadata: { + redirectUris: [`https://${host}/callback`], + postLogoutRedirectUris: [`https://${host}`], + }, + }); + }); +}); diff --git a/packages/core/src/libraries/protected-app.ts b/packages/core/src/libraries/protected-app.ts index c56da9d83..d2d65f237 100644 --- a/packages/core/src/libraries/protected-app.ts +++ b/packages/core/src/libraries/protected-app.ts @@ -1,4 +1,12 @@ +import { type Application } from '@logto/schemas'; +import { isValidSubdomain } from '@logto/shared'; + import { EnvSet } from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { + defaultProtectedAppPageRules, + defaultProtectedAppSessionDuration, +} from '#src/routes/applications/constants.js'; import type Queries from '#src/tenants/Queries.js'; import SystemContext from '#src/tenants/SystemContext.js'; import assertThat from '#src/utils/assert-that.js'; @@ -28,7 +36,7 @@ const deleteRemoteAppConfigs = async (host: string): Promise => { export const createProtectedAppLibrary = (queries: Queries) => { const { - applications: { findApplicationById }, + applications: { findApplicationById, findApplicationByProtectedAppHost }, } = queries; const syncAppConfigsToRemote = async (applicationId: string): Promise => { @@ -58,8 +66,59 @@ export const createProtectedAppLibrary = (queries: Queries) => { ); }; + /** + * Build application data for protected app + * check if subdomain is valid + * generate host based on subdomain + * generate default protectedAppMetadata based on host and origin + * generate redirectUris and postLogoutRedirectUris based on host + */ + const checkAndBuildProtectedAppData = async ({ + subDomain, + origin, + }: { + subDomain: string; + origin: string; + }): Promise> => { + assertThat( + isValidSubdomain(subDomain), + new RequestError({ + code: 'application.invalid_subdomain', + status: 422, + }) + ); + + // Skip for integration test, use empty value instead + const { domain } = EnvSet.values.isIntegrationTest ? { domain: '' } : await getProviderConfig(); + const host = `${subDomain}.${domain}`; + + const application = await findApplicationByProtectedAppHost(host); + + assertThat( + !application, + new RequestError({ + code: 'application.protected_application_subdomain_exists', + status: 422, + }) + ); + + return { + protectedAppMetadata: { + host, + origin, + sessionDuration: defaultProtectedAppSessionDuration, + pageRules: defaultProtectedAppPageRules, + }, + oidcClientMetadata: { + redirectUris: [`https://${host}/callback`], + postLogoutRedirectUris: [`https://${host}`], + }, + }; + }; + return { syncAppConfigsToRemote, deleteRemoteAppConfigs, + checkAndBuildProtectedAppData, }; }; diff --git a/packages/core/src/queries/application.test.ts b/packages/core/src/queries/application.test.ts index 655d9ed3c..40c03dda3 100644 --- a/packages/core/src/queries/application.test.ts +++ b/packages/core/src/queries/application.test.ts @@ -1,4 +1,4 @@ -import { Applications } from '@logto/schemas'; +import { ApplicationType, Applications } from '@logto/schemas'; import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { snakeCase } from 'snake-case'; @@ -22,6 +22,7 @@ const { createApplicationQueries } = await import('./application.js'); const { findTotalNumberOfApplications, findApplicationById, + findApplicationByProtectedAppHost, insertApplication, updateApplicationById, deleteApplicationById, @@ -66,6 +67,27 @@ describe('application query', () => { await findApplicationById(id); }); + it('findApplicationByProtectedAppHost', async () => { + const host = 'host.protected.app'; + const rowData = { host }; + + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.protectedAppMetadata}->>'host' = $1 + and ${fields.type} = $2 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([host, ApplicationType.Protected]); + + return createMockQueryResult([rowData]); + }); + + await findApplicationByProtectedAppHost(host); + }); + it('insertApplication', async () => { const keys = excludeAutoSetFields(Applications.fieldKeys); diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 4af637c9f..7a20d559a 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -130,6 +130,14 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications); + const findApplicationByProtectedAppHost = async (host: string) => + pool.maybeOne(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.protectedAppMetadata}->>'host' = ${host} + and ${fields.type} = ${ApplicationType.Protected} + `); + const insertApplication = buildInsertIntoWithPool(pool)(Applications, { returning: true, }); @@ -231,6 +239,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { findApplications, findTotalNumberOfApplications, findApplicationById, + findApplicationByProtectedAppHost, insertApplication, updateApplication, updateApplicationById, diff --git a/packages/core/src/routes/applications/application.test.ts b/packages/core/src/routes/applications/application.test.ts index 9baf5c661..8ab49eb12 100644 --- a/packages/core/src/routes/applications/application.test.ts +++ b/packages/core/src/routes/applications/application.test.ts @@ -13,6 +13,11 @@ const findApplicationById = jest.fn(async () => mockApplication); const deleteApplicationById = jest.fn(); const syncAppConfigsToRemote = jest.fn(); const deleteRemoteAppConfigs = jest.fn(); +const checkAndBuildProtectedAppData = jest.fn(async () => { + const { oidcClientMetadata, protectedAppMetadata } = mockProtectedApplication; + + return { oidcClientMetadata, protectedAppMetadata }; +}); const updateApplicationById = jest.fn( async (_, data: Partial): Promise => ({ ...mockApplication, @@ -46,7 +51,11 @@ const tenantContext = new MockTenant( undefined, { quota: createMockQuotaLibrary(), - protectedApps: { syncAppConfigsToRemote, deleteRemoteAppConfigs }, + protectedApps: { + syncAppConfigsToRemote, + deleteRemoteAppConfigs, + checkAndBuildProtectedAppData, + }, } ); @@ -109,7 +118,7 @@ describe('application route', () => { name, type, protectedAppMetadata: { - host: protectedAppMetadata?.host, + subDomain: 'mock', origin: protectedAppMetadata?.origin, }, }); diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 7bb1a2dda..87371dc36 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -24,7 +24,6 @@ import { applicationCreateGuard, applicationPatchGuard, } from './types.js'; -import { buildProtectedAppData } from './utils.js'; const includesInternalAdminRole = (roles: Readonly>) => roles.some(({ role: { name } }) => name === InternalRole.Admin); @@ -152,8 +151,6 @@ export default function applicationRoutes( ); } - // TODO LOG-7794: check and add domain to Cloudflare - const application = await insertApplication({ id: generateStandardId(), secret: generateStandardSecret(), @@ -161,7 +158,7 @@ export default function applicationRoutes( ...conditional( rest.type === ApplicationType.Protected && protectedAppMetadata && - buildProtectedAppData(protectedAppMetadata) + (await protectedApps.checkAndBuildProtectedAppData(protectedAppMetadata)) ), ...rest, }); diff --git a/packages/core/src/routes/applications/types.ts b/packages/core/src/routes/applications/types.ts index b2bf5c8cf..51e5b93b6 100644 --- a/packages/core/src/routes/applications/types.ts +++ b/packages/core/src/routes/applications/types.ts @@ -31,7 +31,7 @@ const applicationCreateGuardWithProtectedAppMetadata = originalApplicationCreate .extend({ protectedAppMetadata: z .object({ - host: z.string(), + subDomain: z.string(), origin: z.string(), }) .optional(), diff --git a/packages/core/src/routes/applications/utils.ts b/packages/core/src/routes/applications/utils.ts deleted file mode 100644 index 797c35037..000000000 --- a/packages/core/src/routes/applications/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defaultProtectedAppPageRules, defaultProtectedAppSessionDuration } from './constants.js'; - -/** - * Build application data for protected app - * generate default protectedAppMetadata based on host and origin - * generate redirectUris and postLogoutRedirectUris based on host - */ -export const buildProtectedAppData = ({ host, origin }: { host: string; origin: string }) => ({ - protectedAppMetadata: { - host, - origin, - sessionDuration: defaultProtectedAppSessionDuration, - pageRules: defaultProtectedAppPageRules, - }, - oidcClientMetadata: { - redirectUris: [`https://${host}/callback`], - postLogoutRedirectUris: [`https://${host}`], - }, -}); diff --git a/packages/core/src/utils/cloudflare/kv.ts b/packages/core/src/utils/cloudflare/kv.ts index d4b9d3136..ecbd0996d 100644 --- a/packages/core/src/utils/cloudflare/kv.ts +++ b/packages/core/src/utils/cloudflare/kv.ts @@ -45,10 +45,7 @@ export const updateProtectedAppSiteConfigs = async ( Authorization: `Bearer ${auth.apiToken}`, }, throwHttpErrors: false, - json: { - metadata: {}, - value: JSON.stringify(value), - }, + json: value, } ); diff --git a/packages/integration-tests/src/tests/api/application/application.test.ts b/packages/integration-tests/src/tests/api/application/application.test.ts index ecf399f3f..54a49b629 100644 --- a/packages/integration-tests/src/tests/api/application/application.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.test.ts @@ -52,7 +52,7 @@ describe('admin console application', () => { const applicationName = 'test-protected-app'; const metadata = { origin: 'https://example.com', - host: 'example.protected.app', + subDomain: 'example', }; const application = await createApplication(applicationName, ApplicationType.Protected, { @@ -63,11 +63,35 @@ describe('admin console application', () => { expect(application.name).toBe(applicationName); expect(application.type).toBe(ApplicationType.Protected); expect(application.protectedAppMetadata).toHaveProperty('origin', metadata.origin); - expect(application.protectedAppMetadata).toHaveProperty('host', metadata.host); + expect(application.protectedAppMetadata?.host).toContain(metadata.subDomain); expect(application.protectedAppMetadata).toHaveProperty('sessionDuration'); await deleteApplication(application.id); }); + it('should throw error when creating protected application with existing subdomain', async () => { + const applicationName = 'test-protected-app'; + const metadata = { + origin: 'https://example.com', + subDomain: 'example', + }; + + const application = await createApplication(applicationName, ApplicationType.Protected, { + // @ts-expect-error the create guard has been modified + protectedAppMetadata: metadata, + }); + await expectRejects( + createApplication('test-create-app', ApplicationType.Protected, { + // @ts-expect-error the create guard has been modified + protectedAppMetadata: metadata, + }), + { + code: 'application.protected_application_subdomain_exists', + statusCode: 422, + } + ); + await deleteApplication(application.id); + }); + it('should throw error when creating a protected application with invalid type', async () => { await expectRejects(createApplication('test-create-app', ApplicationType.Protected), { code: 'application.protected_app_metadata_is_required', @@ -105,7 +129,7 @@ describe('admin console application', () => { it('should update application details for protected app successfully', async () => { const metadata = { origin: 'https://example.com', - host: 'example.protected.app', + subDomain: 'example', }; const application = await createApplication('test-update-app', ApplicationType.Protected, { diff --git a/packages/phrases/src/locales/de/errors/application.ts b/packages/phrases/src/locales/de/errors/application.ts index f5c5f1ee8..c540e613f 100644 --- a/packages/phrases/src/locales/de/errors/application.ts +++ b/packages/phrases/src/locales/de/errors/application.ts @@ -16,6 +16,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 5f1f2f0cc..e43441e7d 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -11,6 +11,9 @@ const application = { cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API', protected_application_only: 'The feature is only available for protected applications.', protected_application_misconfigured: 'Protected application is misconfigured.', + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + invalid_subdomain: 'Invalid subdomain.', }; 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 bc6da806a..b877c3488 100644 --- a/packages/phrases/src/locales/es/errors/application.ts +++ b/packages/phrases/src/locales/es/errors/application.ts @@ -16,6 +16,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 a1b5f8163..1f345f3dc 100644 --- a/packages/phrases/src/locales/fr/errors/application.ts +++ b/packages/phrases/src/locales/fr/errors/application.ts @@ -17,6 +17,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 b5e7f2fe5..a4fd1f189 100644 --- a/packages/phrases/src/locales/it/errors/application.ts +++ b/packages/phrases/src/locales/it/errors/application.ts @@ -17,6 +17,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 d363a650e..ae022c391 100644 --- a/packages/phrases/src/locales/ja/errors/application.ts +++ b/packages/phrases/src/locales/ja/errors/application.ts @@ -16,6 +16,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 c44254394..1cd2d9366 100644 --- a/packages/phrases/src/locales/ko/errors/application.ts +++ b/packages/phrases/src/locales/ko/errors/application.ts @@ -15,6 +15,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 9d14f857e..48e8a30c6 100644 --- a/packages/phrases/src/locales/pl-pl/errors/application.ts +++ b/packages/phrases/src/locales/pl-pl/errors/application.ts @@ -15,6 +15,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 66ac09aec..4abecd0a5 100644 --- a/packages/phrases/src/locales/pt-br/errors/application.ts +++ b/packages/phrases/src/locales/pt-br/errors/application.ts @@ -16,6 +16,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 45d4a4763..9e314fe40 100644 --- a/packages/phrases/src/locales/pt-pt/errors/application.ts +++ b/packages/phrases/src/locales/pt-pt/errors/application.ts @@ -16,6 +16,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 590ba45cb..24f772472 100644 --- a/packages/phrases/src/locales/ru/errors/application.ts +++ b/packages/phrases/src/locales/ru/errors/application.ts @@ -17,6 +17,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 b89cfd962..a4c635212 100644 --- a/packages/phrases/src/locales/tr-tr/errors/application.ts +++ b/packages/phrases/src/locales/tr-tr/errors/application.ts @@ -15,6 +15,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 3d3ecd654..7dc94ba72 100644 --- a/packages/phrases/src/locales/zh-cn/errors/application.ts +++ b/packages/phrases/src/locales/zh-cn/errors/application.ts @@ -14,6 +14,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 f34e50f36..070ab27cd 100644 --- a/packages/phrases/src/locales/zh-hk/errors/application.ts +++ b/packages/phrases/src/locales/zh-hk/errors/application.ts @@ -14,6 +14,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; 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 6669e2391..26037b8f6 100644 --- a/packages/phrases/src/locales/zh-tw/errors/application.ts +++ b/packages/phrases/src/locales/zh-tw/errors/application.ts @@ -14,6 +14,11 @@ const application = { protected_application_only: 'The feature is only available for protected applications.', /** UNTRANSLATED */ protected_application_misconfigured: 'Protected application is misconfigured.', + /** UNTRANSLATED */ + protected_application_subdomain_exists: + 'The subdomain of Protected application is already in use.', + /** UNTRANSLATED */ + invalid_subdomain: 'Invalid subdomain.', }; export default Object.freeze(application); diff --git a/packages/schemas/src/types/system.ts b/packages/schemas/src/types/system.ts index 5a63d603e..aeab1d47a 100644 --- a/packages/schemas/src/types/system.ts +++ b/packages/schemas/src/types/system.ts @@ -158,6 +158,8 @@ export const protectedAppConfigProviderDataGuard = z.object({ namespaceIdentifier: z.string(), /* Key prefix for protected app config */ keyName: z.string(), + /* The default domain (e.g protected.app) for the protected app */ + domain: z.string(), apiToken: z.string(), // Requires account permission for "KV Storage Edit" }); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 47a6cae3e..9861d6aa0 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './ttl-cache.js'; export * from './id.js'; export * from './user-display-name.js'; export * from './phone.js'; +export * from './sub-domain.js'; diff --git a/packages/shared/src/utils/sub-domain.test.ts b/packages/shared/src/utils/sub-domain.test.ts new file mode 100644 index 000000000..e89a797f6 --- /dev/null +++ b/packages/shared/src/utils/sub-domain.test.ts @@ -0,0 +1,18 @@ +import { isValidSubdomain } from './sub-domain.js'; + +describe('isValidSubdomain()', () => { + it('should return true for valid subdomains', () => { + expect(isValidSubdomain('a')).toBe(true); + expect(isValidSubdomain('1')).toBe(true); + expect(isValidSubdomain('a1')).toBe(true); + expect(isValidSubdomain('a1-b2')).toBe(true); + }); + + it('should return false for invalid subdomains', () => { + expect(isValidSubdomain('')).toBe(false); + expect(isValidSubdomain('a1-')).toBe(false); + expect(isValidSubdomain('-a1')).toBe(false); + expect(isValidSubdomain('a1-')).toBe(false); + expect(isValidSubdomain('a1.b')).toBe(false); + }); +}); diff --git a/packages/shared/src/utils/sub-domain.ts b/packages/shared/src/utils/sub-domain.ts new file mode 100644 index 000000000..31dd700d0 --- /dev/null +++ b/packages/shared/src/utils/sub-domain.ts @@ -0,0 +1,7 @@ +/** + * Checks if the subdomain string is valid + * @param subdomain subdomain string + * @returns boolean indicating whether the subdomain is valid + */ +export const isValidSubdomain = (subdomain: string): boolean => + /^([\da-z]([\da-z-]{0,61}[\da-z])?)$/i.test(subdomain);