0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): integrate with cloudflare (#3919)

* feat(phrases): add phrases

* feat(core): add domains library

* feat(core): integrate cloudflare

* chore: changeset

* fix: read envset inside

* fix: fix cloudflare request

* fix: fix integration test envset problem

* fix: cr fixes
This commit is contained in:
wangsijie 2023-06-01 15:32:49 +08:00 committed by GitHub
parent 1d7330835c
commit fa0dbafe81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 639 additions and 33 deletions

View file

@ -0,0 +1,7 @@
---
"@logto/core": minor
"@logto/phrases": minor
"@logto/schemas": minor
---
Add custom domain support

View file

@ -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 &

View file

@ -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,
};

View file

@ -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',
};

View file

@ -34,7 +34,7 @@ try {
loadConnectorFactories(),
checkRowLevelSecurity(sharedAdminPool),
checkAlterationState(sharedAdminPool),
SystemContext.shared.loadStorageProviderConfig(sharedAdminPool),
SystemContext.shared.loadProviderConfigs(sharedAdminPool),
]);
// Import last until init completed

View file

@ -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');
});
});

View file

@ -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<typeof createDomainLibrary>;
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<Domain> => {
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<Domain> => {
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<Domain> => {
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,
};
};

View file

@ -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<Record<string, unknown>>(sql`
select ${fields.value} from ${table}
where ${fields.key} = ${key}
`);
return {
findSystemByKey,
};
};

View file

@ -23,7 +23,17 @@ const domains = {
deleteDomainById: jest.fn(),
};
const tenantContext = new MockTenant(undefined, { domains });
const syncDomainStatus = jest.fn(async (domain: Domain): Promise<Domain> => domain);
const addDomainToCloudflare = jest.fn(async (domain: Domain): Promise<Domain> => domain);
const mockLibraries = {
domains: {
syncDomainStatus,
addDomainToCloudflare,
},
};
const tenantContext = new MockTenant(undefined, { domains }, undefined, mockLibraries);
const domainRoutes = await pickDefault(import('./domain.js'));

View file

@ -10,18 +10,24 @@ import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function domainRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
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<T extends AuthedRouter>(
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<T extends AuthedRouter>(
})
);
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();
}

View file

@ -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,

View file

@ -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<unknown> => {
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();
});
});

View file

@ -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<Record<string, unknown>>(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<T>(
pool: CommonQueryMethods,
key: SystemKey,
guard: ZodType<T>
): Promise<T | undefined> {
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;
}
}

View file

@ -0,0 +1 @@
export const baseUrl = new URL('https://api.cloudflare.com/client/v4');

View file

@ -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;
};

View file

@ -0,0 +1,5 @@
import { mockCloudflareData } from '#src/__mocks__/domain.js';
export const mockCustomHostnameResponse = async (identifier?: string) => {
return mockCloudflareData;
};

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
export const cloudflareResponseGuard = z.object({
success: z.boolean(),
result: z.unknown(),
});

View file

@ -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;
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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;

View file

@ -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.',
};

View file

@ -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つしか持てません。',
};

View file

@ -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: '하나의 맞춤 도메인만 사용할 수 있습니다.',
};

View file

@ -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ę.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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: 'Вы можете использовать только один пользовательский домен.',
};

View file

@ -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: 'Cloudflaredan beklenmeyen bir yanıt alındı.',
limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.',
};

View file

@ -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: '仅限一个自定义域名。',
};

View file

@ -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;

View file

@ -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: '您只能擁有一個自訂網域。',
};

View file

@ -19,3 +19,10 @@ export const domainResponseGuard = Domains.guard.pick({
});
export type DomainResponse = z.infer<typeof domainResponseGuard>;
export enum DomainStatus {
PendingVerification = 'PendingVerification',
PendingSsl = 'PendingSsl',
Active = 'Active',
Error = 'Error',
}