mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
refactor(core)!: separate admin console server
This commit is contained in:
parent
4a84162722
commit
639b5c836a
9 changed files with 122 additions and 209 deletions
|
@ -1,18 +1,18 @@
|
|||
import fs from 'fs/promises';
|
||||
import http2 from 'http2';
|
||||
|
||||
import { deduplicate } from '@silverhand/essentials';
|
||||
import { toTitle } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import type Koa from 'koa';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { defaultTenant, tenantPool } from '#src/tenants/index.js';
|
||||
|
||||
const logListening = () => {
|
||||
const { localhostUrl, endpoint } = EnvSet.values;
|
||||
const logListening = (type: 'core' | 'admin' = 'core') => {
|
||||
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
|
||||
|
||||
for (const url of deduplicate([localhostUrl, endpoint.toString()])) {
|
||||
console.log(chalk.bold(chalk.green(`App is running at ${url}`)));
|
||||
for (const url of urlSet.deduplicated()) {
|
||||
console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url}`)));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -43,23 +43,35 @@ export default async function initApp(app: Koa): Promise<void> {
|
|||
return tenant.run(ctx, next);
|
||||
});
|
||||
|
||||
const { isHttpsEnabled, httpsCert, httpsKey, port } = EnvSet.values;
|
||||
const { isHttpsEnabled, httpsCert, httpsKey, urlSet, adminUrlSet } = EnvSet.values;
|
||||
|
||||
if (isHttpsEnabled && httpsCert && httpsKey) {
|
||||
http2
|
||||
.createSecureServer(
|
||||
const createHttp2Server = async () =>
|
||||
http2.createSecureServer(
|
||||
{ cert: await fs.readFile(httpsCert), key: await fs.readFile(httpsKey) },
|
||||
app.callback()
|
||||
)
|
||||
.listen(port, () => {
|
||||
logListening();
|
||||
});
|
||||
);
|
||||
|
||||
const coreServer = await createHttp2Server();
|
||||
const adminServer = await createHttp2Server();
|
||||
|
||||
coreServer.listen(urlSet.port, () => {
|
||||
logListening();
|
||||
});
|
||||
|
||||
adminServer.listen(adminUrlSet.port, () => {
|
||||
logListening('admin');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Chrome doesn't allow insecure http/2 servers
|
||||
app.listen(port, () => {
|
||||
app.listen(urlSet.port, () => {
|
||||
logListening();
|
||||
});
|
||||
|
||||
app.listen(adminUrlSet.port, () => {
|
||||
logListening('admin');
|
||||
});
|
||||
}
|
||||
|
|
70
packages/core/src/env-set/GlobalValues.ts
Normal file
70
packages/core/src/env-set/GlobalValues.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import net from 'net';
|
||||
|
||||
import { tryThat } from '@logto/shared';
|
||||
import { assertEnv, deduplicate, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||
|
||||
import { isTrue } from './parameters.js';
|
||||
import { throwErrorWithDsnMessage } from './throw-errors.js';
|
||||
|
||||
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
|
||||
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
|
||||
|
||||
type MultiTenancyMode = 'domain' | 'env';
|
||||
|
||||
export class UrlSet {
|
||||
public readonly port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
|
||||
public readonly localhostUrl = `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${
|
||||
this.port
|
||||
}`;
|
||||
|
||||
public readonly endpoint = getEnv(this.envPrefix + 'ENDPOINT', this.localhostUrl);
|
||||
|
||||
constructor(
|
||||
public readonly isHttpsEnabled: boolean,
|
||||
protected readonly defaultPort: number,
|
||||
protected readonly envPrefix: string = ''
|
||||
) {}
|
||||
|
||||
public deduplicated(): string[] {
|
||||
return deduplicate([this.localhostUrl, this.endpoint]);
|
||||
}
|
||||
}
|
||||
|
||||
export default class GlobalValues {
|
||||
public readonly isProduction = getEnv('NODE_ENV') === 'production';
|
||||
public readonly isTest = getEnv('NODE_ENV') === 'test';
|
||||
public readonly isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
|
||||
|
||||
public readonly httpsCert = process.env.HTTPS_CERT_PATH;
|
||||
public readonly httpsKey = process.env.HTTPS_KEY_PATH;
|
||||
public readonly isHttpsEnabled = Boolean(this.httpsCert && this.httpsKey);
|
||||
|
||||
public readonly isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
|
||||
|
||||
public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001);
|
||||
public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_');
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
|
||||
public readonly developmentTenantId = getEnv(developmentTenantIdKey);
|
||||
public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES');
|
||||
public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID');
|
||||
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
|
||||
public readonly ignoreConnectorVersionCheck = isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK'));
|
||||
|
||||
public get dbUrl(): string {
|
||||
return this.databaseUrl;
|
||||
}
|
||||
|
||||
public get endpoint(): string {
|
||||
return this.urlSet.endpoint;
|
||||
}
|
||||
|
||||
public get multiTenancyMode(): MultiTenancyMode {
|
||||
const { hostname } = new URL(this.endpoint);
|
||||
|
||||
return this.isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost'
|
||||
? 'domain'
|
||||
: 'env';
|
||||
}
|
||||
}
|
|
@ -1,8 +1,4 @@
|
|||
import net from 'net';
|
||||
|
||||
import { tryThat } from '@logto/shared';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
import type { DatabasePool } from 'slonik';
|
||||
|
@ -10,12 +6,12 @@ import type { DatabasePool } from 'slonik';
|
|||
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
import GlobalValues from './GlobalValues.js';
|
||||
import { checkAlterationState } from './check-alteration-state.js';
|
||||
import createPool from './create-pool.js';
|
||||
import createQueryClient from './create-query-client.js';
|
||||
import loadOidcValues from './oidc.js';
|
||||
import { isTrue } from './parameters.js';
|
||||
import { throwErrorWithDsnMessage, throwNotLoadedError } from './throw-errors.js';
|
||||
import { throwNotLoadedError } from './throw-errors.js';
|
||||
|
||||
export enum MountedApps {
|
||||
Api = 'api',
|
||||
|
@ -25,72 +21,8 @@ export enum MountedApps {
|
|||
Welcome = 'welcome',
|
||||
}
|
||||
|
||||
type MultiTenancyMode = 'domain' | 'env';
|
||||
|
||||
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
|
||||
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
|
||||
|
||||
const loadEnvValues = () => {
|
||||
const isProduction = getEnv('NODE_ENV') === 'production';
|
||||
const isTest = getEnv('NODE_ENV') === 'test';
|
||||
const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
|
||||
const isHttpsEnabled = Boolean(process.env.HTTPS_CERT_PATH && process.env.HTTPS_KEY_PATH);
|
||||
const isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
|
||||
const port = Number(getEnv('PORT', '3001'));
|
||||
const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||
const endpoint = getEnv('ENDPOINT', localhostUrl);
|
||||
const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
|
||||
|
||||
const { hostname } = new URL(endpoint);
|
||||
const multiTenancyMode: MultiTenancyMode =
|
||||
isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost' ? 'domain' : 'env';
|
||||
const developmentTenantId = getEnv(developmentTenantIdKey);
|
||||
|
||||
if (!isMultiTenancy && developmentTenantId) {
|
||||
throw new Error(
|
||||
`Multi-tenancy is disabled but development tenant env \`${developmentTenantIdKey}\` found. Please enable multi-tenancy by setting \`${enableMultiTenancyKey}\` to true.`
|
||||
);
|
||||
}
|
||||
|
||||
if (isMultiTenancy && multiTenancyMode === 'env') {
|
||||
if (isProduction) {
|
||||
throw new Error(
|
||||
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
|
||||
'An endpoint with a valid domain is required for multi-tenancy mode.'
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[warn]',
|
||||
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
|
||||
`Logto is using \`${developmentTenantIdKey}\` env (current value: ${developmentTenantId}) for tenant recognition which is not supported in production.`
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
isTest,
|
||||
isIntegrationTest,
|
||||
isProduction,
|
||||
isHttpsEnabled,
|
||||
isMultiTenancy,
|
||||
httpsCert: process.env.HTTPS_CERT_PATH,
|
||||
httpsKey: process.env.HTTPS_KEY_PATH,
|
||||
port,
|
||||
localhostUrl,
|
||||
endpoint,
|
||||
multiTenancyMode,
|
||||
dbUrl: databaseUrl,
|
||||
userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'),
|
||||
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
|
||||
developmentTenantId,
|
||||
trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')),
|
||||
adminConsoleUrl: appendPath(endpoint, '/console'),
|
||||
ignoreConnectorVersionCheck: isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK')),
|
||||
});
|
||||
};
|
||||
|
||||
export class EnvSet {
|
||||
static values: ReturnType<typeof loadEnvValues> = loadEnvValues();
|
||||
static values = new GlobalValues();
|
||||
static default = new EnvSet(EnvSet.values.dbUrl);
|
||||
|
||||
static get isTest() {
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
export default function koaWelcomeProxy<
|
||||
export default function koaConsoleRedirectProxy<
|
||||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
>(queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
const { hasActiveUsers } = queries.users;
|
||||
const { adminConsoleUrl } = EnvSet.values;
|
||||
|
||||
return async (ctx) => {
|
||||
if (await hasActiveUsers()) {
|
||||
ctx.redirect(adminConsoleUrl.toString());
|
||||
return async (ctx, next) => {
|
||||
const hasUser = await hasActiveUsers();
|
||||
|
||||
if ((ctx.path === '/' || ctx.path === '/console') && !hasUser) {
|
||||
ctx.redirect('/console/welcome');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.redirect(appendPath(adminConsoleUrl, '/welcome').toString());
|
||||
if (ctx.path === '/console/welcome' && hasUser) {
|
||||
ctx.redirect('/console');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import koaRootProxy from './koa-root-proxy.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
describe('koaRootProxy', () => {
|
||||
const next = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('empty path should directly return', async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: '/',
|
||||
});
|
||||
|
||||
await koaRootProxy()(ctx, next);
|
||||
|
||||
expect(next).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('non-empty path should return next', async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: '/console',
|
||||
});
|
||||
await koaRootProxy()(ctx, next);
|
||||
expect(next).toBeCalled();
|
||||
});
|
||||
});
|
|
@ -1,26 +0,0 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
export default function koaRootProxy<
|
||||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
>(): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
const { endpoint } = EnvSet.values;
|
||||
|
||||
return async (ctx, next) => {
|
||||
const requestPath = ctx.request.path;
|
||||
|
||||
// Redirect root path to the Admin Console welcome page
|
||||
if (requestPath === '/') {
|
||||
ctx.redirect(appendPath(endpoint, '/welcome').toString());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const hasActiveUsers = jest.fn();
|
||||
const queries = new MockQueries({ users: { hasActiveUsers } });
|
||||
|
||||
const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js'));
|
||||
|
||||
describe('koaWelcomeProxy', () => {
|
||||
const next = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should redirect to admin console if has AdminUsers', async () => {
|
||||
const { endpoint } = EnvSet.values;
|
||||
hasActiveUsers.mockResolvedValue(true);
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `/${MountedApps.Welcome}`,
|
||||
});
|
||||
|
||||
await koaWelcomeProxy(queries)(ctx, next);
|
||||
|
||||
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}`);
|
||||
expect(next).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should redirect to welcome page if has no Users', async () => {
|
||||
const { endpoint } = EnvSet.values;
|
||||
hasActiveUsers.mockResolvedValue(false);
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `/${MountedApps.Welcome}`,
|
||||
});
|
||||
|
||||
await koaWelcomeProxy(queries)(ctx, next);
|
||||
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}/welcome`);
|
||||
expect(next).not.toBeCalled();
|
||||
});
|
||||
});
|
|
@ -14,11 +14,8 @@ import { appendPath } from '#src/utils/url.js';
|
|||
import { getConstantClientMetadata } from './utils.js';
|
||||
|
||||
const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
|
||||
const { localhostUrl, adminConsoleUrl } = EnvSet.values;
|
||||
const urls = deduplicate([
|
||||
appendPath(localhostUrl, '/console').toString(),
|
||||
adminConsoleUrl.toString(),
|
||||
]);
|
||||
const { adminUrlSet } = EnvSet.values;
|
||||
const urls = adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString());
|
||||
|
||||
return {
|
||||
...getConstantClientMetadata(envSet, ApplicationType.SPA),
|
||||
|
@ -32,10 +29,10 @@ const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
|
|||
const buildDemoAppUris = (
|
||||
oidcClientMetadata: OidcClientMetadata
|
||||
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
|
||||
const { localhostUrl, endpoint } = EnvSet.values;
|
||||
const { urlSet } = EnvSet.values;
|
||||
const urls = [
|
||||
appendPath(localhostUrl, MountedApps.DemoApp).toString(),
|
||||
appendPath(endpoint, MountedApps.DemoApp).toString(),
|
||||
appendPath(urlSet.localhostUrl, MountedApps.DemoApp).toString(),
|
||||
appendPath(urlSet.endpoint, MountedApps.DemoApp).toString(),
|
||||
];
|
||||
|
||||
const data = {
|
||||
|
|
|
@ -8,14 +8,13 @@ import type Provider from 'oidc-provider';
|
|||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
|
||||
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
|
||||
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
|
||||
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
|
||||
import koaI18next from '#src/middleware/koa-i18next.js';
|
||||
import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
|
||||
import koaRootProxy from '#src/middleware/koa-root-proxy.js';
|
||||
import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
|
||||
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
|
||||
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
|
||||
import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js';
|
||||
import type { ModelRouters } from '#src/model-routers/index.js';
|
||||
import { createModelRouters } from '#src/model-routers/index.js';
|
||||
import initOidc from '#src/oidc/init.js';
|
||||
|
@ -80,17 +79,17 @@ export default class Tenant implements TenantContext {
|
|||
app.use(koaConnectorErrorHandler());
|
||||
app.use(koaI18next());
|
||||
|
||||
// Mount APIs
|
||||
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
|
||||
app.use(mount('/api', apisApp));
|
||||
|
||||
app.use(mount('/', koaRootProxy()));
|
||||
|
||||
app.use(mount('/' + MountedApps.Welcome, koaWelcomeProxy(queries)));
|
||||
|
||||
// Mount Admin Console
|
||||
app.use(koaConsoleRedirectProxy(queries));
|
||||
app.use(
|
||||
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
|
||||
);
|
||||
|
||||
// Mount demo app
|
||||
app.use(
|
||||
mount(
|
||||
'/' + MountedApps.DemoApp,
|
||||
|
@ -101,6 +100,7 @@ export default class Tenant implements TenantContext {
|
|||
)
|
||||
);
|
||||
|
||||
// Mount UI
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
|
||||
|
||||
this.app = app;
|
||||
|
|
Loading…
Add table
Reference in a new issue