diff --git a/.changeset/spicy-nails-share.md b/.changeset/spicy-nails-share.md new file mode 100644 index 000000000..31fae4bf1 --- /dev/null +++ b/.changeset/spicy-nails-share.md @@ -0,0 +1,7 @@ +--- +"@logto/core": minor +"@logto/phrases": minor +"@logto/schemas": minor +--- + +Add custom domain support diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 005cd9ab0..aed3fbade 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -31,7 +31,7 @@ jobs: run: | pnpm -r build ./.scripts/package.sh - + - name: Build and package (Cloud) if: matrix.env == 'cloud' run: | @@ -109,13 +109,19 @@ jobs: -p ../logto \ --du ../logto.tar.gz \ ${{ contains(matrix.target, 'cloud') && '--cloud' || '' }} - + - name: Check and add mock connectors working-directory: tests run: | npm run cli connector list -- -p ../logto | grep OFFICIAL npm run cli connector link -- --mock -p ../logto + - name: Setup mock Cloudflare Hostname Provider config + working-directory: tests + run: npm run cli db system set cloudflareHostnameProvider '{"zoneId":"mock-zone-id","apiToken":"","fallbackOrigin":"mock.logto.dev"}' + env: + DB_URL: postgres://postgres:postgres@localhost:5432/postgres + - name: Run Logto working-directory: logto/ run: nohup npm start > nohup.out 2> nohup.err < /dev/null & diff --git a/packages/core/src/__mocks__/domain.ts b/packages/core/src/__mocks__/domain.ts index 6216e6aca..ef3633e13 100644 --- a/packages/core/src/__mocks__/domain.ts +++ b/packages/core/src/__mocks__/domain.ts @@ -1,4 +1,4 @@ -import { type Domain, type DomainResponse } from '@logto/schemas'; +import { type CloudflareData, type Domain, type DomainResponse } from '@logto/schemas'; export const mockNanoIdForDomain = 'random_string'; @@ -21,3 +21,47 @@ export const mockDomain: Domain = { updatedAt: mockCreatedAtForDomain, createdAt: mockCreatedAtForDomain, }; + +export const mockHostnameId = 'mock-hostname-id'; +export const mockTxtName = 'mock-txt-name'; +export const mockTxtValue = 'mock-txt-value'; +export const mockSslTxtName = 'mock-ssl-txt-name'; +export const mockSslTxtValue = 'mock-ssl-txt-value'; + +export const mockCloudflareData: CloudflareData = { + id: mockHostnameId, + status: 'pending', + ssl: { + status: 'pending', + txt_name: mockSslTxtName, + txt_value: mockSslTxtValue, + }, + ownership_verification: { + type: 'TXT', + name: mockTxtName, + value: mockTxtValue, + }, +}; + +export const mockCloudflareDataPendingSSL: CloudflareData = { + id: `${mockHostnameId}-pending-ssl`, + status: 'active', + ssl: { + status: 'pending', + txt_name: mockSslTxtName, + txt_value: mockSslTxtValue, + }, +}; + +export const mockCloudflareDataActive: CloudflareData = { + id: `${mockHostnameId}-active`, + status: 'active', + ssl: { + status: 'active', + }, +}; + +export const mockDomainWithCloudflareData: Domain = { + ...mockDomain, + cloudflareData: mockCloudflareData, +}; diff --git a/packages/core/src/__mocks__/system.ts b/packages/core/src/__mocks__/system.ts new file mode 100644 index 000000000..9e2e315cf --- /dev/null +++ b/packages/core/src/__mocks__/system.ts @@ -0,0 +1,11 @@ +export const mockStorageProviderData = { + provider: 'AzureStorage', + container: 'container-name', + connectionString: 'connection-string', +}; + +export const mockHostnameProviderData = { + apiToken: 'api-token', + zoneId: 'zone-id', + fallbackOrigin: 'logto.com', +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cbc102b13..b06f4d14c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,7 +34,7 @@ try { loadConnectorFactories(), checkRowLevelSecurity(sharedAdminPool), checkAlterationState(sharedAdminPool), - SystemContext.shared.loadStorageProviderConfig(sharedAdminPool), + SystemContext.shared.loadProviderConfigs(sharedAdminPool), ]); // Import last until init completed diff --git a/packages/core/src/libraries/domain.test.ts b/packages/core/src/libraries/domain.test.ts new file mode 100644 index 000000000..54f390264 --- /dev/null +++ b/packages/core/src/libraries/domain.test.ts @@ -0,0 +1,159 @@ +import { DomainStatus } from '@logto/schemas'; +import { createMockUtils } from '@logto/shared/esm'; + +import { + mockCloudflareData, + mockCloudflareDataActive, + mockCloudflareDataPendingSSL, + mockDomain, + mockDomainWithCloudflareData, + mockSslTxtName, + mockSslTxtValue, + mockTxtName, + mockTxtValue, +} from '#src/__mocks__/domain.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import SystemContext from '#src/tenants/SystemContext.js'; + +const { jest } = import.meta; +const { mockEsm } = createMockUtils(jest); + +const { getCustomHostname, createCustomHostname } = mockEsm( + '#src/utils/cloudflare/index.js', + () => ({ + createCustomHostname: jest.fn(async () => mockCloudflareData), + getCustomHostname: jest.fn(async () => mockCloudflareData), + }) +); + +const { MockQueries } = await import('#src/test-utils/tenant.js'); +const { createDomainLibrary } = await import('./domain.js'); + +const updateDomainById = jest.fn(async (_, data) => data); +const { syncDomainStatus, addDomainToCloudflare } = createDomainLibrary( + new MockQueries({ domains: { updateDomainById } }) +); + +const fallbackOrigin = 'fake_origin'; +beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + SystemContext.shared.hostnameProviderConfig = { + zoneId: 'fake_zone_id', + apiToken: '', + fallbackOrigin, + }; +}); + +afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + SystemContext.shared.hostnameProviderConfig = undefined; +}); + +describe('addDomainToCloudflare()', () => { + it('should call createCustomHostname and return cloudflare data', async () => { + const response = await addDomainToCloudflare(mockDomain); + expect(createCustomHostname).toBeCalledTimes(1); + expect(updateDomainById).toBeCalledTimes(1); + expect(response.cloudflareData).toMatchObject(mockCloudflareData); + }); +}); + +describe('syncDomainStatus()', () => { + it('should fail if domain.cloudflareData is missing', async () => { + await expect(syncDomainStatus(mockDomain)).rejects.toMatchError( + new RequestError({ code: 'domain.cloudflare_data_missing' }) + ); + }); + + it('should get new cloudflare data', async () => { + const response = await syncDomainStatus({ + ...mockDomainWithCloudflareData, + cloudflareData: mockCloudflareDataPendingSSL, + }); + expect(getCustomHostname).toBeCalledTimes(1); + expect(response.cloudflareData).toMatchObject(mockCloudflareData); + }); + + it('should sync and get result with pendingVerification', async () => { + const response = await syncDomainStatus(mockDomainWithCloudflareData); + expect(response.status).toBe(DomainStatus.PendingVerification); + expect(response.dnsRecords).toContainEqual({ + type: 'CNAME', + name: mockDomainWithCloudflareData.domain, + value: fallbackOrigin, + }); + expect(response.dnsRecords).toContainEqual({ + type: 'TXT', + name: mockTxtName, + value: mockTxtValue, + }); + expect(response.dnsRecords).toContainEqual({ + type: 'TXT', + name: mockSslTxtName, + value: mockSslTxtValue, + }); + }); + + it('should sync and get result with pendingSsl', async () => { + getCustomHostname.mockResolvedValueOnce(mockCloudflareDataPendingSSL); + const response = await syncDomainStatus(mockDomainWithCloudflareData); + expect(response.status).toBe(DomainStatus.PendingSsl); + expect(response.dnsRecords).not.toContainEqual({ + type: 'CNAME', + name: mockDomainWithCloudflareData.domain, + value: fallbackOrigin, + }); + expect(response.dnsRecords).not.toContainEqual({ + type: 'TXT', + name: mockTxtName, + value: mockTxtValue, + }); + expect(response.dnsRecords).toContainEqual({ + type: 'TXT', + name: mockSslTxtName, + value: mockSslTxtValue, + }); + }); + + it('should sync and get result with active', async () => { + getCustomHostname.mockResolvedValueOnce(mockCloudflareDataActive); + const response = await syncDomainStatus(mockDomainWithCloudflareData); + expect(response.status).toBe(DomainStatus.Active); + expect(response.dnsRecords).not.toContainEqual({ + type: 'CNAME', + name: mockDomainWithCloudflareData.domain, + value: fallbackOrigin, + }); + expect(response.dnsRecords).not.toContainEqual({ + type: 'TXT', + name: mockTxtName, + value: mockTxtValue, + }); + expect(response.dnsRecords).not.toContainEqual({ + type: 'TXT', + name: mockSslTxtName, + value: mockSslTxtValue, + }); + }); + + it('should sync and get verification error', async () => { + getCustomHostname.mockResolvedValueOnce({ + ...mockCloudflareDataActive, + verification_errors: ['fake_error'], + }); + const response = await syncDomainStatus(mockDomainWithCloudflareData); + expect(response.errorMessage).toContain('fake_error'); + }); + + it('should sync and get ssl error', async () => { + getCustomHostname.mockResolvedValueOnce({ + ...mockCloudflareDataActive, + ssl: { + ...mockCloudflareDataActive.ssl, + validation_errors: [{ message: 'fake_error' }], + }, + }); + const response = await syncDomainStatus(mockDomainWithCloudflareData); + expect(response.errorMessage).toContain('fake_error'); + }); +}); diff --git a/packages/core/src/libraries/domain.ts b/packages/core/src/libraries/domain.ts new file mode 100644 index 000000000..33210487e --- /dev/null +++ b/packages/core/src/libraries/domain.ts @@ -0,0 +1,109 @@ +import { + type CloudflareData, + type Domain, + type DomainDnsRecords, + DomainStatus, +} from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; + +import type Queries from '#src/tenants/Queries.js'; +import SystemContext from '#src/tenants/SystemContext.js'; +import assertThat from '#src/utils/assert-that.js'; +import { getCustomHostname, createCustomHostname } from '#src/utils/cloudflare/index.js'; + +export type DomainLibrary = ReturnType; + +const getDomainStatusFromCloudflareData = (data: CloudflareData): DomainStatus => { + switch (data.status) { + case 'active': { + return data.ssl.status === 'active' ? DomainStatus.Active : DomainStatus.PendingSsl; + } + default: { + return DomainStatus.PendingVerification; + } + } +}; + +export const createDomainLibrary = (queries: Queries) => { + const { + domains: { updateDomainById }, + } = queries; + + const syncDomainStatusFromCloudflareData = async ( + domain: Domain, + cloudflareData: CloudflareData, + origin: string + ): Promise => { + const status = getDomainStatusFromCloudflareData(cloudflareData); + const { + verification_errors: verificationErrors, + ssl: { validation_errors: sslVerificationErrors, txt_name: txtName, txt_value: txtValue }, + ownership_verification: ownershipVerification, + } = cloudflareData; + + const errorMessage: string = [ + ...(verificationErrors ?? []), + ...(sslVerificationErrors ?? []).map(({ message }) => message), + ] + .filter(Boolean) + .join('\n'); + + const sslRecord = conditional( + txtName && txtValue && { type: 'TXT', name: txtName, value: txtValue } + ); + const cnameRecord = conditional( + (status === DomainStatus.PendingVerification || status === DomainStatus.Error) && { + type: 'CNAME', + name: domain.domain, + value: origin, + } + ); + const dnsRecords: DomainDnsRecords = [cnameRecord, ownershipVerification, sslRecord].filter( + Boolean + ); + + return updateDomainById( + domain.id, + { cloudflareData, errorMessage, dnsRecords, status }, + 'replace' + ); + }; + + const syncDomainStatus = async (domain: Domain): Promise => { + const { hostnameProviderConfig } = SystemContext.shared; + assertThat(hostnameProviderConfig, 'domain.not_configured'); + + assertThat(domain.cloudflareData, 'domain.cloudflare_data_missing'); + + const cloudflareData = await getCustomHostname( + hostnameProviderConfig, + domain.cloudflareData.id + ); + + return syncDomainStatusFromCloudflareData( + domain, + cloudflareData, + hostnameProviderConfig.fallbackOrigin + ); + }; + + const addDomainToCloudflare = async (domain: Domain): Promise => { + const { hostnameProviderConfig } = SystemContext.shared; + assertThat(hostnameProviderConfig, 'domain.not_configured'); + + const cloudflareData = await createCustomHostname(hostnameProviderConfig, domain.domain); + return syncDomainStatusFromCloudflareData( + { + ...domain, + cloudflareData, + }, + cloudflareData, + hostnameProviderConfig.fallbackOrigin + ); + }; + + return { + syncDomainStatus, + addDomainToCloudflare, + }; +}; diff --git a/packages/core/src/queries/system.ts b/packages/core/src/queries/system.ts new file mode 100644 index 000000000..1bc855000 --- /dev/null +++ b/packages/core/src/queries/system.ts @@ -0,0 +1,18 @@ +import { type SystemKey, Systems } from '@logto/schemas'; +import { convertToIdentifiers } from '@logto/shared'; +import type { CommonQueryMethods } from 'slonik'; +import { sql } from 'slonik'; + +const { table, fields } = convertToIdentifiers(Systems); + +export const createSystemsQuery = (pool: CommonQueryMethods) => { + const findSystemByKey = async (key: SystemKey) => + pool.maybeOne>(sql` + select ${fields.value} from ${table} + where ${fields.key} = ${key} + `); + + return { + findSystemByKey, + }; +}; diff --git a/packages/core/src/routes/domain.test.ts b/packages/core/src/routes/domain.test.ts index 42df2a007..1d14f83aa 100644 --- a/packages/core/src/routes/domain.test.ts +++ b/packages/core/src/routes/domain.test.ts @@ -23,7 +23,17 @@ const domains = { deleteDomainById: jest.fn(), }; -const tenantContext = new MockTenant(undefined, { domains }); +const syncDomainStatus = jest.fn(async (domain: Domain): Promise => domain); +const addDomainToCloudflare = jest.fn(async (domain: Domain): Promise => domain); + +const mockLibraries = { + domains: { + syncDomainStatus, + addDomainToCloudflare, + }, +}; + +const tenantContext = new MockTenant(undefined, { domains }, undefined, mockLibraries); const domainRoutes = await pickDefault(import('./domain.js')); diff --git a/packages/core/src/routes/domain.ts b/packages/core/src/routes/domain.ts index 66652cdfc..297a7944d 100644 --- a/packages/core/src/routes/domain.ts +++ b/packages/core/src/routes/domain.ts @@ -10,18 +10,24 @@ import assertThat from '#src/utils/assert-that.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; export default function domainRoutes( - ...[router, { queries }]: RouterInitArgs + ...[router, { queries, libraries }]: RouterInitArgs ) { const { domains: { findAllDomains, findDomainById, insertDomain, deleteDomainById }, } = queries; + const { + domains: { syncDomainStatus, addDomainToCloudflare }, + } = libraries; router.get( '/domains', koaGuard({ response: domainResponseGuard.array(), status: 200 }), async (ctx, next) => { const domains = await findAllDomains(); - ctx.body = domains.map((domain) => pick(domain, ...domainSelectFields)); + const syncedDomains = await Promise.all( + domains.map(async (domain) => syncDomainStatus(domain)) + ); + ctx.body = syncedDomains.map((domain) => pick(domain, ...domainSelectFields)); return next(); } @@ -39,9 +45,9 @@ export default function domainRoutes( params: { id }, } = ctx.guard; - const domain = await findDomainById(id); + const syncedDomain = await syncDomainStatus(await findDomainById(id)); - ctx.body = pick(domain, ...domainSelectFields); + ctx.body = pick(syncedDomain, ...domainSelectFields); return next(); } @@ -64,13 +70,15 @@ export default function domainRoutes( }) ); - const domain = await insertDomain({ - ...ctx.guard.body, - id: generateStandardId(), - }); + const syncedDomain = await addDomainToCloudflare( + await insertDomain({ + ...ctx.guard.body, + id: generateStandardId(), + }) + ); ctx.status = 201; - ctx.body = pick(domain, ...domainSelectFields); + ctx.body = pick(syncedDomain, ...domainSelectFields); return next(); } diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index c77c01981..7a608b5e2 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -1,5 +1,6 @@ import { createApplicationLibrary } from '#src/libraries/application.js'; import type { ConnectorLibrary } from '#src/libraries/connector.js'; +import { createDomainLibrary } from '#src/libraries/domain.js'; import { createHookLibrary } from '#src/libraries/hook/index.js'; import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPhraseLibrary } from '#src/libraries/phrase.js'; @@ -21,6 +22,7 @@ export default class Libraries { passcodes = createPasscodeLibrary(this.queries, this.connectors); applications = createApplicationLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries); + domains = createDomainLibrary(this.queries); constructor( public readonly tenantId: string, diff --git a/packages/core/src/tenants/SystemContex.test.ts b/packages/core/src/tenants/SystemContex.test.ts new file mode 100644 index 000000000..b83b79c83 --- /dev/null +++ b/packages/core/src/tenants/SystemContex.test.ts @@ -0,0 +1,47 @@ +import { CloudflareKey, StorageProviderKey } from '@logto/schemas'; +import { createMockUtils, pickDefault } from '@logto/shared/esm'; +import { createMockPool } from 'slonik'; + +import { mockHostnameProviderData, mockStorageProviderData } from '#src/__mocks__/system.js'; + +const { jest } = import.meta; +const { mockEsm } = createMockUtils(jest); + +const pool = createMockPool({ + query: jest.fn(), +}); + +const findSystemByKey = jest.fn(async (key: string): Promise => { + if (key === StorageProviderKey.StorageProvider) { + return { value: mockStorageProviderData }; + } + + if (key === CloudflareKey.HostnameProvider) { + return { value: mockHostnameProviderData }; + } +}); +mockEsm('#src/queries/system.js', () => ({ + createSystemsQuery: () => ({ + findSystemByKey, + }), +})); + +const SystemContext = await pickDefault(import('./SystemContext.js')); + +describe('SystemContext', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should load loadProviderConfigs', async () => { + await SystemContext.shared.loadProviderConfigs(pool); + expect(SystemContext.shared.storageProviderConfig).toEqual(mockStorageProviderData); + expect(SystemContext.shared.hostnameProviderConfig).toEqual(mockHostnameProviderData); + }); + + it('should ignore invalid value', async () => { + findSystemByKey.mockResolvedValueOnce({ value: 'invalid' }); + await SystemContext.shared.loadProviderConfigs(pool); + expect(SystemContext.shared.storageProviderConfig).toBeUndefined(); + }); +}); diff --git a/packages/core/src/tenants/SystemContext.ts b/packages/core/src/tenants/SystemContext.ts index 936b3f23e..421d992b6 100644 --- a/packages/core/src/tenants/SystemContext.ts +++ b/packages/core/src/tenants/SystemContext.ts @@ -1,35 +1,62 @@ -import type { StorageProviderData } from '@logto/schemas'; -import { storageProviderDataGuard, StorageProviderKey, Systems } from '@logto/schemas'; -import { convertToIdentifiers } from '@logto/shared'; +import { + CloudflareKey, + type HostnameProviderData, + type StorageProviderData, + hostnameProviderDataGuard, + storageProviderDataGuard, + StorageProviderKey, + type SystemKey, +} from '@logto/schemas'; import type { CommonQueryMethods } from 'slonik'; -import { sql } from 'slonik'; +import { type ZodType } from 'zod'; +import { createSystemsQuery } from '#src/queries/system.js'; import { consoleLog } from '#src/utils/console.js'; -const { table, fields } = convertToIdentifiers(Systems); - export default class SystemContext { static shared = new SystemContext(); - public storageProviderConfig: StorageProviderData | undefined; + public storageProviderConfig?: StorageProviderData; + public hostnameProviderConfig?: HostnameProviderData; - async loadStorageProviderConfig(pool: CommonQueryMethods) { - const record = await pool.maybeOne>(sql` - select ${fields.value} from ${table} - where ${fields.key} = ${StorageProviderKey.StorageProvider} - `); + async loadProviderConfigs(pool: CommonQueryMethods) { + await Promise.all([ + (async () => { + this.storageProviderConfig = await this.loadConfig( + pool, + StorageProviderKey.StorageProvider, + storageProviderDataGuard + ); + })(), + (async () => { + this.hostnameProviderConfig = await this.loadConfig( + pool, + CloudflareKey.HostnameProvider, + hostnameProviderDataGuard + ); + })(), + ]); + } + + private async loadConfig( + pool: CommonQueryMethods, + key: SystemKey, + guard: ZodType + ): Promise { + const { findSystemByKey } = createSystemsQuery(pool); + const record = await findSystemByKey(key); if (!record) { return; } - const result = storageProviderDataGuard.safeParse(record.value); + const result = guard.safeParse(record.value); if (!result.success) { - consoleLog.error('Failed to parse storage provider config:', result.error); + consoleLog.error(`Failed to parse ${key} config:`, result.error); return; } - this.storageProviderConfig = result.data; + return result.data; } } diff --git a/packages/core/src/utils/cloudflare/consts.ts b/packages/core/src/utils/cloudflare/consts.ts new file mode 100644 index 000000000..7748f5319 --- /dev/null +++ b/packages/core/src/utils/cloudflare/consts.ts @@ -0,0 +1 @@ +export const baseUrl = new URL('https://api.cloudflare.com/client/v4'); diff --git a/packages/core/src/utils/cloudflare/index.ts b/packages/core/src/utils/cloudflare/index.ts new file mode 100644 index 000000000..e9056dee3 --- /dev/null +++ b/packages/core/src/utils/cloudflare/index.ts @@ -0,0 +1,65 @@ +import { type HostnameProviderData, cloudflareDataGuard } from '@logto/schemas'; +import { got } from 'got'; + +import assertThat from '../assert-that.js'; + +import { baseUrl } from './consts.js'; +import { mockCustomHostnameResponse } from './mock.js'; +import { parseCloudflareResponse } from './utils.js'; + +export const createCustomHostname = async (auth: HostnameProviderData, hostname: string) => { + const { + EnvSet: { + values: { isIntegrationTest }, + }, + } = await import('#src/env-set/index.js'); + if (isIntegrationTest) { + return mockCustomHostnameResponse(); + } + + const response = await got.post(new URL(baseUrl, `/zones/${auth.zoneId}/custom_hostnames`), { + headers: { + Authorization: `Bearer ${auth.apiToken}`, + }, + json: { + hostname, + ssl: { method: 'txt', type: 'dv', settings: { min_tls_version: '1.0' } }, + }, + }); + + assertThat(response.ok, 'domain.cloudflare_unknown_error'); + + const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body)); + + assertThat(result.success, 'domain.cloudflare_response_error'); + + return result.data; +}; + +export const getCustomHostname = async (auth: HostnameProviderData, identifier: string) => { + const { + EnvSet: { + values: { isIntegrationTest }, + }, + } = await import('#src/env-set/index.js'); + if (isIntegrationTest) { + return mockCustomHostnameResponse(identifier); + } + + const response = await got.get( + new URL(baseUrl, `/zones/${auth.zoneId}/custom_hostnames/${identifier}`), + { + headers: { + Authorization: `Bearer ${auth.apiToken}`, + }, + } + ); + + assertThat(response.ok, 'domain.cloudflare_unknown_error'); + + const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body)); + + assertThat(result.success, 'domain.cloudflare_response_error'); + + return result.data; +}; diff --git a/packages/core/src/utils/cloudflare/mock.ts b/packages/core/src/utils/cloudflare/mock.ts new file mode 100644 index 000000000..88a8b7adc --- /dev/null +++ b/packages/core/src/utils/cloudflare/mock.ts @@ -0,0 +1,5 @@ +import { mockCloudflareData } from '#src/__mocks__/domain.js'; + +export const mockCustomHostnameResponse = async (identifier?: string) => { + return mockCloudflareData; +}; diff --git a/packages/core/src/utils/cloudflare/types.ts b/packages/core/src/utils/cloudflare/types.ts new file mode 100644 index 000000000..a870da425 --- /dev/null +++ b/packages/core/src/utils/cloudflare/types.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const cloudflareResponseGuard = z.object({ + success: z.boolean(), + result: z.unknown(), +}); diff --git a/packages/core/src/utils/cloudflare/utils.ts b/packages/core/src/utils/cloudflare/utils.ts new file mode 100644 index 000000000..4f03e21fe --- /dev/null +++ b/packages/core/src/utils/cloudflare/utils.ts @@ -0,0 +1,13 @@ +import { parseJson } from '@logto/connector-kit'; + +import assertThat from '../assert-that.js'; + +import { cloudflareResponseGuard } from './types.js'; + +export const parseCloudflareResponse = (body: string) => { + const result = cloudflareResponseGuard.safeParse(parseJson(body)); + + assertThat(result.success && result.data.success, 'domain.cloudflare_response_error'); + + return result.data.result; +}; diff --git a/packages/phrases/src/locales/de/errors/domain.ts b/packages/phrases/src/locales/de/errors/domain.ts index 5476c8cf1..2a855c241 100644 --- a/packages/phrases/src/locales/de/errors/domain.ts +++ b/packages/phrases/src/locales/de/errors/domain.ts @@ -1,4 +1,9 @@ const domain = { + not_configured: 'Der Domain-Hostname-Anbieter ist nicht konfiguriert.', + cloudflare_data_missing: 'cloudflare_data fehlt, bitte überprüfen Sie es.', + cloudflare_unknown_error: + 'Beim Anfordern der Cloudflare-API ist ein unbekannter Fehler aufgetreten', + cloudflare_response_error: 'Vom Cloudflare wurde eine unerwartete Antwort erhalten.', limit_to_one_domain: 'Sie können nur eine benutzerdefinierte Domain haben.', }; diff --git a/packages/phrases/src/locales/en/errors/domain.ts b/packages/phrases/src/locales/en/errors/domain.ts index 317b4cc97..68eae5ca7 100644 --- a/packages/phrases/src/locales/en/errors/domain.ts +++ b/packages/phrases/src/locales/en/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'Domain hostname provider is not configured.', + cloudflare_data_missing: 'cloudflare_data is missing, please check.', + cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API', + cloudflare_response_error: 'Got unexpected response from Cloudflare.', limit_to_one_domain: 'You can only have one custom domain.', }; diff --git a/packages/phrases/src/locales/es/errors/domain.ts b/packages/phrases/src/locales/es/errors/domain.ts index aa1737a74..0c3aea249 100644 --- a/packages/phrases/src/locales/es/errors/domain.ts +++ b/packages/phrases/src/locales/es/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'El proveedor de nombres de dominio del host no está configurado.', + cloudflare_data_missing: 'cloudflare_data falta, por favor revise.', + cloudflare_unknown_error: 'Se produjo un error desconocido al solicitar la API de Cloudflare', + cloudflare_response_error: 'Recibió una respuesta inesperada de Cloudflare.', limit_to_one_domain: 'Solo puedes tener un dominio personalizado.', }; diff --git a/packages/phrases/src/locales/fr/errors/domain.ts b/packages/phrases/src/locales/fr/errors/domain.ts index a09f1d6a3..c0b7fd68c 100644 --- a/packages/phrases/src/locales/fr/errors/domain.ts +++ b/packages/phrases/src/locales/fr/errors/domain.ts @@ -1,5 +1,9 @@ -const domaine = { - limit_to_one_domain: "Vous ne pouvez avoir qu'un seul domaine personnalisé.", +const domain = { + not_configured: "Le fournisseur de nom de domaine de l'hôte n'est pas configuré.", + cloudflare_data_missing: 'les données de cloudflare sont manquantes, veuillez vérifier.', + cloudflare_unknown_error: "Erreur inconnue lors de la requête de l'API Cloudflare", + cloudflare_response_error: 'Réponse inattendue de Cloudflare', + limit_to_one_domain: "Vous ne pouvez avoir qu'un seul domaine personnalisé", }; -export default domaine; +export default domain; diff --git a/packages/phrases/src/locales/it/errors/domain.ts b/packages/phrases/src/locales/it/errors/domain.ts index 9a4ecdedb..3ffbee78d 100644 --- a/packages/phrases/src/locales/it/errors/domain.ts +++ b/packages/phrases/src/locales/it/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: "Il fornitore del nome di dominio dell'host non è configurato.", + cloudflare_data_missing: 'Dati cloudflare mancanti, per favore verificare.', + cloudflare_unknown_error: 'Errore sconosciuto durante la richiesta di API Cloudflare', + cloudflare_response_error: 'Ricevuta una risposta non prevista da Cloudflare.', limit_to_one_domain: 'Puoi avere solo un dominio personalizzato.', }; diff --git a/packages/phrases/src/locales/ja/errors/domain.ts b/packages/phrases/src/locales/ja/errors/domain.ts index 0f6b7604a..d32bb72be 100644 --- a/packages/phrases/src/locales/ja/errors/domain.ts +++ b/packages/phrases/src/locales/ja/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'Domain hostname provider が設定されていません。', + cloudflare_data_missing: 'cloudflare_data が見つかりませんでした。確認してください。', + cloudflare_unknown_error: 'Cloudflare API のリクエスト中に未知のエラーが発生しました。', + cloudflare_response_error: 'Cloudflare から予期しない応答がありました。', limit_to_one_domain: 'カスタムドメインは1つしか持てません。', }; diff --git a/packages/phrases/src/locales/ko/errors/domain.ts b/packages/phrases/src/locales/ko/errors/domain.ts index ce2cac956..79b0cd0f6 100644 --- a/packages/phrases/src/locales/ko/errors/domain.ts +++ b/packages/phrases/src/locales/ko/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: '도메인 호스트 이름 공급 업체가 구성되어 있지 않습니다.', + cloudflare_data_missing: 'cloudflare_data 가 없습니다. 확인하십시오.', + cloudflare_unknown_error: 'Cloudflare API 요청시 알 수 없는 오류 발생', + cloudflare_response_error: 'Cloudflare 로부터 예상치 못한 응답을 받았습니다.', limit_to_one_domain: '하나의 맞춤 도메인만 사용할 수 있습니다.', }; diff --git a/packages/phrases/src/locales/pl-pl/errors/domain.ts b/packages/phrases/src/locales/pl-pl/errors/domain.ts index 7703c53b5..3441f8bd6 100644 --- a/packages/phrases/src/locales/pl-pl/errors/domain.ts +++ b/packages/phrases/src/locales/pl-pl/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'Dostawca nazw domen dla hosta nie jest skonfigurowany.', + cloudflare_data_missing: 'brak danych cloudflare, proszę sprawdzić.', + cloudflare_unknown_error: 'Otrzymano nieznany błąd podczas żądania API Cloudflare', + cloudflare_response_error: 'Otrzymano nieoczekiwaną odpowiedź od Cloudflare.', limit_to_one_domain: 'Możesz mieć tylko jedną niestandardową domenę.', }; diff --git a/packages/phrases/src/locales/pt-br/errors/domain.ts b/packages/phrases/src/locales/pt-br/errors/domain.ts index b7fb32218..0a13dd38b 100644 --- a/packages/phrases/src/locales/pt-br/errors/domain.ts +++ b/packages/phrases/src/locales/pt-br/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'O provedor de nome de domínio do host não está configurado.', + cloudflare_data_missing: 'cloudflare_data está faltando, por favor verifique.', + cloudflare_unknown_error: 'Recebido erro desconhecido ao solicitar API do Cloudflare', + cloudflare_response_error: 'Recebido resposta inesperada do Cloudflare.', limit_to_one_domain: 'Você só pode ter um domínio personalizado.', }; diff --git a/packages/phrases/src/locales/pt-pt/errors/domain.ts b/packages/phrases/src/locales/pt-pt/errors/domain.ts index b7fb32218..1447d0e61 100644 --- a/packages/phrases/src/locales/pt-pt/errors/domain.ts +++ b/packages/phrases/src/locales/pt-pt/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'O provedor de nome de host de domínio não está configurado.', + cloudflare_data_missing: 'cloudflare_data está faltando, por favor verifique.', + cloudflare_unknown_error: 'Obteve um erro desconhecido ao solicitar a API Cloudflare', + cloudflare_response_error: 'Obteve uma resposta inesperada da Cloudflare.', limit_to_one_domain: 'Você só pode ter um domínio personalizado.', }; diff --git a/packages/phrases/src/locales/ru/errors/domain.ts b/packages/phrases/src/locales/ru/errors/domain.ts index d77076e51..dbc16086a 100644 --- a/packages/phrases/src/locales/ru/errors/domain.ts +++ b/packages/phrases/src/locales/ru/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'Провайдер доменных имен хоста не настроен.', + cloudflare_data_missing: 'cloudflare_data отсутствует, проверьте, пожалуйста.', + cloudflare_unknown_error: 'Получена неизвестная ошибка при запросе к API Cloudflare', + cloudflare_response_error: 'Получен неожиданный ответ от Cloudflare.', limit_to_one_domain: 'Вы можете использовать только один пользовательский домен.', }; diff --git a/packages/phrases/src/locales/tr-tr/errors/domain.ts b/packages/phrases/src/locales/tr-tr/errors/domain.ts index fa4e05136..1d348a3f1 100644 --- a/packages/phrases/src/locales/tr-tr/errors/domain.ts +++ b/packages/phrases/src/locales/tr-tr/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: 'Alan adı ana bilgisayar sağlayıcısı yapılandırılmamış.', + cloudflare_data_missing: 'cloudflare_data eksik, lütfen kontrol edin.', + cloudflare_unknown_error: 'Cloudflare API isteği yapılırken bilinmeyen bir hata oluştu.', + cloudflare_response_error: 'Cloudflare’dan beklenmeyen bir yanıt alındı.', limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.', }; diff --git a/packages/phrases/src/locales/zh-cn/errors/domain.ts b/packages/phrases/src/locales/zh-cn/errors/domain.ts index f46e40b68..c90714a3e 100644 --- a/packages/phrases/src/locales/zh-cn/errors/domain.ts +++ b/packages/phrases/src/locales/zh-cn/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: '域名主机提供商尚未配置。', + cloudflare_data_missing: 'cloudflare_data 缺失,请检查。', + cloudflare_unknown_error: '请求 Cloudflare API 时出现未知错误。', + cloudflare_response_error: '从 Cloudflare 得到意外的响应。', limit_to_one_domain: '仅限一个自定义域名。', }; diff --git a/packages/phrases/src/locales/zh-hk/errors/domain.ts b/packages/phrases/src/locales/zh-hk/errors/domain.ts index ccac86854..33a54a0e2 100644 --- a/packages/phrases/src/locales/zh-hk/errors/domain.ts +++ b/packages/phrases/src/locales/zh-hk/errors/domain.ts @@ -1,5 +1,9 @@ const domain = { - limit_to_one_domain: '您只能有一个自定义域名。', + not_configured: '域名主機供應商未設定。', + cloudflare_data_missing: 'cloudflare_data 缺失,請檢查。', + cloudflare_unknown_error: '獲取 Cloudflare API 時發生未知錯誤', + cloudflare_response_error: '從 Cloudflare 獲取到意外的響應', + limit_to_one_domain: '您只能擁有一個自定義域名。', }; export default domain; diff --git a/packages/phrases/src/locales/zh-tw/errors/domain.ts b/packages/phrases/src/locales/zh-tw/errors/domain.ts index c8048307f..d2864ca80 100644 --- a/packages/phrases/src/locales/zh-tw/errors/domain.ts +++ b/packages/phrases/src/locales/zh-tw/errors/domain.ts @@ -1,4 +1,8 @@ const domain = { + not_configured: '域名主機名稱提供者未配置。', + cloudflare_data_missing: 'cloudflare_data 缺失,請確認。', + cloudflare_unknown_error: '在請求 Cloudflare API 時出現未知錯誤', + cloudflare_response_error: '從 Cloudflare 收到意外回應', limit_to_one_domain: '您只能擁有一個自訂網域。', }; diff --git a/packages/schemas/src/types/domain.ts b/packages/schemas/src/types/domain.ts index e8e37b15e..01be9816f 100644 --- a/packages/schemas/src/types/domain.ts +++ b/packages/schemas/src/types/domain.ts @@ -19,3 +19,10 @@ export const domainResponseGuard = Domains.guard.pick({ }); export type DomainResponse = z.infer; + +export enum DomainStatus { + PendingVerification = 'PendingVerification', + PendingSsl = 'PendingSsl', + Active = 'Active', + Error = 'Error', +}