diff --git a/packages/core/src/libraries/protected-app.test.ts b/packages/core/src/libraries/protected-app.test.ts index 14cdf0354..05eb9021e 100644 --- a/packages/core/src/libraries/protected-app.test.ts +++ b/packages/core/src/libraries/protected-app.test.ts @@ -18,12 +18,13 @@ import { mockFallbackOrigin } from '#src/utils/cloudflare/mock.js'; const { jest } = import.meta; const { mockEsmWithActual } = createMockUtils(jest); -const { updateProtectedAppSiteConfigs } = await mockEsmWithActual( +const { updateProtectedAppSiteConfigs, deleteCustomHostname } = await mockEsmWithActual( '#src/utils/cloudflare/index.js', () => ({ updateProtectedAppSiteConfigs: jest.fn(), getCustomHostname: jest.fn(async () => mockCloudflareData), getFallbackOrigin: jest.fn(async () => mockFallbackOrigin), + deleteCustomHostname: jest.fn(), }) ); @@ -36,12 +37,12 @@ const updateApplicationById = jest.fn(async (id: string, data: Partial { expect(updateApplicationById).not.toHaveBeenCalled(); }); }); + +describe('deleteDomainFromRemote()', () => { + it('should call deleteCustomHostname', async () => { + await deleteDomainFromRemote('id'); + expect(deleteCustomHostname).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/libraries/protected-app.ts b/packages/core/src/libraries/protected-app.ts index 55e3175f6..fa9b7e37e 100644 --- a/packages/core/src/libraries/protected-app.ts +++ b/packages/core/src/libraries/protected-app.ts @@ -17,6 +17,7 @@ import SystemContext from '#src/tenants/SystemContext.js'; import assertThat from '#src/utils/assert-that.js'; import { createCustomHostname, + deleteCustomHostname, deleteProtectedAppSiteConfigs, getCustomHostname, getDomainStatusFromCloudflareData, @@ -95,6 +96,14 @@ const addDomainToRemote = async ( }; }; +/** + * Call Cloudflare API to delete the domain (custom hostname) + */ +const deleteDomainFromRemote = async (id: string) => { + const hostnameProviderConfig = await getHostnameProviderConfig(); + await deleteCustomHostname(hostnameProviderConfig, id); +}; + export const createProtectedAppLibrary = (queries: Queries) => { const { applications: { findApplicationById, findApplicationByProtectedAppHost, updateApplicationById }, @@ -274,5 +283,6 @@ export const createProtectedAppLibrary = (queries: Queries) => { getDefaultDomain, addDomainToRemote, syncAppCustomDomainStatus, + deleteDomainFromRemote, }; }; diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json b/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json index 24f99d1c2..039c90862 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json +++ b/packages/core/src/routes/applications/application-protected-app-metadata.openapi.json @@ -41,6 +41,20 @@ } } } + }, + "/api/applications/{id}/protected-app-metadata/custom-domains/{domain}": { + "delete": { + "summary": "Delete a custom domain.", + "description": "Add a custom domain.", + "responses": { + "204": { + "description": "The domain has been deleted." + }, + "404": { + "description": "Can not find the domain." + } + } + } } } } diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.test.ts b/packages/core/src/routes/applications/application-protected-app-metadata.test.ts index a9415e40b..e41eeae1e 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.test.ts +++ b/packages/core/src/routes/applications/application-protected-app-metadata.test.ts @@ -2,7 +2,7 @@ import { type Application, DomainStatus } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; import { type Nullable } from '@silverhand/essentials'; -import { mockProtectedApplication } from '#src/__mocks__/index.js'; +import { mockCloudflareData, mockProtectedApplication } from '#src/__mocks__/index.js'; import { mockIdGenerators } from '#src/test-utils/nanoid.js'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -38,6 +38,8 @@ const syncAppCustomDomainStatus = jest.fn(async () => ({ }, })); const syncAppConfigsToRemote = jest.fn(); +const deleteDomainFromRemote = jest.fn(); +const deleteRemoteAppConfigs = jest.fn(); await mockIdGenerators(); @@ -52,7 +54,13 @@ const tenantContext = new MockTenant( }, undefined, { - protectedApps: { addDomainToRemote, syncAppCustomDomainStatus, syncAppConfigsToRemote }, + protectedApps: { + addDomainToRemote, + syncAppCustomDomainStatus, + syncAppConfigsToRemote, + deleteDomainFromRemote, + deleteRemoteAppConfigs, + }, applications: { validateProtectedApplicationById: jest.fn() }, } ); @@ -63,6 +71,10 @@ const applicationProtectedAppMetadataRoutes = await pickDefault( ); describe('application protected app metadata routes', () => { + afterEach(() => { + updateApplicationById.mockClear(); + }); + const requester = createRequester({ authedRoutes: applicationProtectedAppMetadataRoutes, tenantContext, @@ -131,4 +143,51 @@ describe('application protected app metadata routes', () => { expect(response.status).toEqual(422); }); }); + + describe('DELETE /applications/:applicationId/protected-app-metadata/custom-domains/:domain', () => { + it('should update application, delete remote domain, and delete site configs', async () => { + findApplicationById.mockResolvedValueOnce({ + ...mockProtectedApplication, + protectedAppMetadata: { + ...mockProtectedApplication.protectedAppMetadata, + customDomains: [ + { + ...mockDomainResponse, + cloudflareData: mockCloudflareData, + }, + ], + }, + }); + const response = await requester.delete( + `/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains/${mockDomainResponse.domain}` + ); + expect(response.status).toEqual(204); + expect(updateApplicationById).toHaveBeenCalledWith(mockProtectedApplication.id, { + protectedAppMetadata: { + ...mockProtectedApplication.protectedAppMetadata, + customDomains: [], + }, + oidcClientMetadata: { + postLogoutRedirectUris: [`https://${mockProtectedApplication.protectedAppMetadata.host}`], + redirectUris: [`https://${mockProtectedApplication.protectedAppMetadata.host}/callback`], + }, + }); + expect(deleteDomainFromRemote).toHaveBeenCalledWith(mockCloudflareData.id); + expect(deleteRemoteAppConfigs).toHaveBeenCalledWith(mockDomainResponse.domain); + }); + + it('throw when domain exists', async () => { + findApplicationById.mockResolvedValueOnce({ + ...mockProtectedApplication, + protectedAppMetadata: { + ...mockProtectedApplication.protectedAppMetadata, + customDomains: [mockDomainResponse], + }, + }); + const response = await requester.delete( + `/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains/unexists.com` + ); + expect(response.status).toEqual(404); + }); + }); }); diff --git a/packages/core/src/routes/applications/application-protected-app-metadata.ts b/packages/core/src/routes/applications/application-protected-app-metadata.ts index 867b776e6..104dbb633 100644 --- a/packages/core/src/routes/applications/application-protected-app-metadata.ts +++ b/packages/core/src/routes/applications/application-protected-app-metadata.ts @@ -20,7 +20,13 @@ export default function applicationProtectedAppMetadataRoutes @@ -108,4 +114,62 @@ export default function applicationProtectedAppMetadataRoutes { + const { id, domain } = ctx.guard.params; + + const { protectedAppMetadata, oidcClientMetadata } = await findApplicationById(id); + + const domainObject = protectedAppMetadata?.customDomains?.find( + ({ domain: domainName }) => domainName === domain + ); + + assertThat( + protectedAppMetadata && domainObject, + new RequestError({ + code: 'application.custom_domain_not_found', + status: 404, + }) + ); + + // Remove domain from Cloudflare + if (domainObject.cloudflareData?.id) { + await deleteDomainFromRemote(domainObject.cloudflareData.id); + } + + // Remove site configs + await deleteRemoteAppConfigs(domain); + + await updateApplicationById(id, { + oidcClientMetadata: { + ...oidcClientMetadata, + redirectUris: oidcClientMetadata.redirectUris.filter( + (uri) => uri !== `https://${domain}/callback` + ), + postLogoutRedirectUris: oidcClientMetadata.postLogoutRedirectUris.filter( + (uri) => uri !== `https://${domain}` + ), + }, + protectedAppMetadata: { + ...protectedAppMetadata, + customDomains: protectedAppMetadata.customDomains?.filter( + ({ domain: domainName }) => domainName !== domain + ), + }, + }); + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/phrases/src/locales/de/errors/application.ts b/packages/phrases/src/locales/de/errors/application.ts index d51a3a4c7..d6c38b197 100644 --- a/packages/phrases/src/locales/de/errors/application.ts +++ b/packages/phrases/src/locales/de/errors/application.ts @@ -23,6 +23,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 ba295f358..d382787fc 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -15,6 +15,7 @@ const application = { protected_application_subdomain_exists: 'The subdomain of Protected application is already in use.', invalid_subdomain: 'Invalid subdomain.', + custom_domain_not_found: 'Custom domain not found.', }; 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 7a7a313a4..d35fe4cc2 100644 --- a/packages/phrases/src/locales/es/errors/application.ts +++ b/packages/phrases/src/locales/es/errors/application.ts @@ -23,6 +23,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 d182eb9c0..1b9b47fdd 100644 --- a/packages/phrases/src/locales/fr/errors/application.ts +++ b/packages/phrases/src/locales/fr/errors/application.ts @@ -24,6 +24,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 24c567712..70cf9c9f4 100644 --- a/packages/phrases/src/locales/it/errors/application.ts +++ b/packages/phrases/src/locales/it/errors/application.ts @@ -24,6 +24,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 443f6df73..7e31ab8e5 100644 --- a/packages/phrases/src/locales/ja/errors/application.ts +++ b/packages/phrases/src/locales/ja/errors/application.ts @@ -23,6 +23,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 445b5f874..8ac1edd1f 100644 --- a/packages/phrases/src/locales/ko/errors/application.ts +++ b/packages/phrases/src/locales/ko/errors/application.ts @@ -22,6 +22,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 a66bc7eb1..fbca65d91 100644 --- a/packages/phrases/src/locales/pl-pl/errors/application.ts +++ b/packages/phrases/src/locales/pl-pl/errors/application.ts @@ -22,6 +22,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 53626750a..29f161ecb 100644 --- a/packages/phrases/src/locales/pt-br/errors/application.ts +++ b/packages/phrases/src/locales/pt-br/errors/application.ts @@ -23,6 +23,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 ee23fb35f..2a2231ac8 100644 --- a/packages/phrases/src/locales/pt-pt/errors/application.ts +++ b/packages/phrases/src/locales/pt-pt/errors/application.ts @@ -23,6 +23,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 dcb1e43a2..f36f69da2 100644 --- a/packages/phrases/src/locales/ru/errors/application.ts +++ b/packages/phrases/src/locales/ru/errors/application.ts @@ -24,6 +24,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 b38c6e5eb..0df264439 100644 --- a/packages/phrases/src/locales/tr-tr/errors/application.ts +++ b/packages/phrases/src/locales/tr-tr/errors/application.ts @@ -22,6 +22,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 b10c4c028..8a0551378 100644 --- a/packages/phrases/src/locales/zh-cn/errors/application.ts +++ b/packages/phrases/src/locales/zh-cn/errors/application.ts @@ -21,6 +21,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 be01213ca..9361a19bd 100644 --- a/packages/phrases/src/locales/zh-hk/errors/application.ts +++ b/packages/phrases/src/locales/zh-hk/errors/application.ts @@ -21,6 +21,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; 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 63b88fb4c..162b6fbb8 100644 --- a/packages/phrases/src/locales/zh-tw/errors/application.ts +++ b/packages/phrases/src/locales/zh-tw/errors/application.ts @@ -21,6 +21,8 @@ const application = { 'The subdomain of Protected application is already in use.', /** UNTRANSLATED */ invalid_subdomain: 'Invalid subdomain.', + /** UNTRANSLATED */ + custom_domain_not_found: 'Custom domain not found.', }; export default Object.freeze(application);