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:
parent
1d7330835c
commit
fa0dbafe81
34 changed files with 639 additions and 33 deletions
7
.changeset/spicy-nails-share.md
Normal file
7
.changeset/spicy-nails-share.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@logto/core": minor
|
||||
"@logto/phrases": minor
|
||||
"@logto/schemas": minor
|
||||
---
|
||||
|
||||
Add custom domain support
|
10
.github/workflows/integration-test.yml
vendored
10
.github/workflows/integration-test.yml
vendored
|
@ -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 &
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
11
packages/core/src/__mocks__/system.ts
Normal file
11
packages/core/src/__mocks__/system.ts
Normal 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',
|
||||
};
|
|
@ -34,7 +34,7 @@ try {
|
|||
loadConnectorFactories(),
|
||||
checkRowLevelSecurity(sharedAdminPool),
|
||||
checkAlterationState(sharedAdminPool),
|
||||
SystemContext.shared.loadStorageProviderConfig(sharedAdminPool),
|
||||
SystemContext.shared.loadProviderConfigs(sharedAdminPool),
|
||||
]);
|
||||
|
||||
// Import last until init completed
|
||||
|
|
159
packages/core/src/libraries/domain.test.ts
Normal file
159
packages/core/src/libraries/domain.test.ts
Normal 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');
|
||||
});
|
||||
});
|
109
packages/core/src/libraries/domain.ts
Normal file
109
packages/core/src/libraries/domain.ts
Normal 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,
|
||||
};
|
||||
};
|
18
packages/core/src/queries/system.ts
Normal file
18
packages/core/src/queries/system.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
47
packages/core/src/tenants/SystemContex.test.ts
Normal file
47
packages/core/src/tenants/SystemContex.test.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
1
packages/core/src/utils/cloudflare/consts.ts
Normal file
1
packages/core/src/utils/cloudflare/consts.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const baseUrl = new URL('https://api.cloudflare.com/client/v4');
|
65
packages/core/src/utils/cloudflare/index.ts
Normal file
65
packages/core/src/utils/cloudflare/index.ts
Normal 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;
|
||||
};
|
5
packages/core/src/utils/cloudflare/mock.ts
Normal file
5
packages/core/src/utils/cloudflare/mock.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { mockCloudflareData } from '#src/__mocks__/domain.js';
|
||||
|
||||
export const mockCustomHostnameResponse = async (identifier?: string) => {
|
||||
return mockCloudflareData;
|
||||
};
|
6
packages/core/src/utils/cloudflare/types.ts
Normal file
6
packages/core/src/utils/cloudflare/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const cloudflareResponseGuard = z.object({
|
||||
success: z.boolean(),
|
||||
result: z.unknown(),
|
||||
});
|
13
packages/core/src/utils/cloudflare/utils.ts
Normal file
13
packages/core/src/utils/cloudflare/utils.ts
Normal 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;
|
||||
};
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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つしか持てません。',
|
||||
};
|
||||
|
||||
|
|
|
@ -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: '하나의 맞춤 도메인만 사용할 수 있습니다.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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ę.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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: 'Вы можете использовать только один пользовательский домен.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
};
|
||||
|
||||
|
|
|
@ -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: '仅限一个自定义域名。',
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: '您只能擁有一個自訂網域。',
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue