mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor: implement request id (#5813)
* refactor: implement request id * refactor: fix tests * refactor: add unit tests
This commit is contained in:
parent
5adf3dfad7
commit
a9ccfc738d
43 changed files with 498 additions and 139 deletions
5
.changeset/green-phones-visit.md
Normal file
5
.changeset/green-phones-visit.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@logto/app-insights": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
allow additional telemetry for `trackException()`
|
8
.changeset/grumpy-cougars-perform.md
Normal file
8
.changeset/grumpy-cougars-perform.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
"@logto/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
implement request ID for API requests
|
||||||
|
|
||||||
|
- All requests will now include a request ID in the headers (`Logto-Core-Request-Id`)
|
||||||
|
- Terminal logs will now include the request ID as the prefix
|
|
@ -1,8 +1,11 @@
|
||||||
import { trySafe } from '@silverhand/essentials';
|
import { trySafe } from '@silverhand/essentials';
|
||||||
import type { TelemetryClient } from 'applicationinsights';
|
import type { TelemetryClient } from 'applicationinsights';
|
||||||
|
import { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js';
|
||||||
|
|
||||||
import { normalizeError } from './normalize-error.js';
|
import { normalizeError } from './normalize-error.js';
|
||||||
|
|
||||||
|
export { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js';
|
||||||
|
|
||||||
class AppInsights {
|
class AppInsights {
|
||||||
client?: TelemetryClient;
|
client?: TelemetryClient;
|
||||||
|
|
||||||
|
@ -29,9 +32,14 @@ class AppInsights {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The function is async to avoid blocking the main script and force the use of `await` or `void`. */
|
/**
|
||||||
async trackException(error: unknown) {
|
* The function is async to avoid blocking the main script and force the use of `await` or `void`.
|
||||||
this.client?.trackException({ exception: normalizeError(error) });
|
*
|
||||||
|
* @param error The error to track. It will be normalized for better telemetry.
|
||||||
|
* @param telemetry Additional telemetry to include in the exception.
|
||||||
|
*/
|
||||||
|
async trackException(error: unknown, telemetry?: Partial<ExceptionTelemetry>) {
|
||||||
|
this.client?.trackException({ exception: normalizeError(error), ...telemetry });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,17 +2,21 @@ import fs from 'node:fs/promises';
|
||||||
import http2 from 'node:http2';
|
import http2 from 'node:http2';
|
||||||
|
|
||||||
import { appInsights } from '@logto/app-insights/node';
|
import { appInsights } from '@logto/app-insights/node';
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
import { toTitle, trySafe } from '@silverhand/essentials';
|
import { toTitle, trySafe } from '@silverhand/essentials';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import type Koa from 'koa';
|
import type Koa from 'koa';
|
||||||
|
import koaLogger from 'koa-logger';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { TenantNotFoundError, tenantPool } from '#src/tenants/index.js';
|
import { TenantNotFoundError, tenantPool } from '#src/tenants/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { buildAppInsightsTelemetry } from '#src/utils/request.js';
|
||||||
import { getTenantId } from '#src/utils/tenant.js';
|
import { getTenantId } from '#src/utils/tenant.js';
|
||||||
|
|
||||||
const logListening = (type: 'core' | 'admin' = 'core') => {
|
const logListening = (type: 'core' | 'admin' = 'core') => {
|
||||||
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
|
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
|
||||||
|
const consoleLog = new ConsoleLog(chalk.magenta(type));
|
||||||
|
|
||||||
for (const url of urlSet.deduplicated()) {
|
for (const url of urlSet.deduplicated()) {
|
||||||
consoleLog.info(chalk.bold(`${toTitle(type)} app is running at ${url.toString()}`));
|
consoleLog.info(chalk.bold(`${toTitle(type)} app is running at ${url.toString()}`));
|
||||||
|
@ -22,6 +26,22 @@ const logListening = (type: 'core' | 'admin' = 'core') => {
|
||||||
const serverTimeout = 120_000;
|
const serverTimeout = 120_000;
|
||||||
|
|
||||||
export default async function initApp(app: Koa): Promise<void> {
|
export default async function initApp(app: Koa): Promise<void> {
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
const requestId = nanoid(16);
|
||||||
|
const consoleLog = new ConsoleLog(chalk.blue(requestId));
|
||||||
|
ctx.requestId = requestId;
|
||||||
|
ctx.console = consoleLog;
|
||||||
|
|
||||||
|
await koaLogger({
|
||||||
|
transporter: (string) => {
|
||||||
|
consoleLog.plain(string);
|
||||||
|
},
|
||||||
|
})(ctx, next);
|
||||||
|
|
||||||
|
// Set the header in the end to avoid other middleware from overwriting it
|
||||||
|
ctx.set('Logto-Core-Request-Id', requestId);
|
||||||
|
});
|
||||||
|
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
if (EnvSet.values.isDomainBasedMultiTenancy && ['/status', '/'].includes(ctx.URL.pathname)) {
|
if (EnvSet.values.isDomainBasedMultiTenancy && ['/status', '/'].includes(ctx.URL.pathname)) {
|
||||||
ctx.status = 204;
|
ctx.status = 204;
|
||||||
|
@ -43,7 +63,7 @@ export default async function initApp(app: Koa): Promise<void> {
|
||||||
|
|
||||||
const tenant = await trySafe(tenantPool.get(tenantId, customEndpoint), (error) => {
|
const tenant = await trySafe(tenantPool.get(tenantId, customEndpoint), (error) => {
|
||||||
ctx.status = error instanceof TenantNotFoundError ? 404 : 500;
|
ctx.status = error instanceof TenantNotFoundError ? 404 : 500;
|
||||||
void appInsights.trackException(error);
|
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
|
@ -56,7 +76,7 @@ export default async function initApp(app: Koa): Promise<void> {
|
||||||
tenant.requestEnd();
|
tenant.requestEnd();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
tenant.requestEnd();
|
tenant.requestEnd();
|
||||||
void appInsights.trackException(error);
|
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,9 @@ import { type Optional, conditional, yes, trySafe } from '@silverhand/essentials
|
||||||
import { createClient, createCluster, type RedisClientType, type RedisClusterType } from 'redis';
|
import { createClient, createCluster, type RedisClientType, type RedisClusterType } from 'redis';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
import { type CacheStore } from './types.js';
|
import { type CacheStore } from './types.js';
|
||||||
|
import { cacheConsole } from './utils.js';
|
||||||
|
|
||||||
abstract class RedisCacheBase implements CacheStore {
|
abstract class RedisCacheBase implements CacheStore {
|
||||||
readonly client?: RedisClientType | RedisClusterType;
|
readonly client?: RedisClientType | RedisClusterType;
|
||||||
|
@ -32,17 +32,17 @@ abstract class RedisCacheBase implements CacheStore {
|
||||||
const pong = await this.ping();
|
const pong = await this.ping();
|
||||||
|
|
||||||
if (pong === 'PONG') {
|
if (pong === 'PONG') {
|
||||||
consoleLog.info('[CACHE] Connected to Redis');
|
cacheConsole.info('Connected to Redis');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
consoleLog.warn('[CACHE] No Redis client initialized, skipping');
|
cacheConsole.warn('No Redis client initialized, skipping');
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
await this.client.disconnect();
|
await this.client.disconnect();
|
||||||
consoleLog.info('[CACHE] Disconnected from Redis');
|
cacheConsole.info('Disconnected from Redis');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
4
packages/core/src/caches/utils.ts
Normal file
4
packages/core/src/caches/utils.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
export const cacheConsole = new ConsoleLog(chalk.magenta('cache'));
|
|
@ -3,9 +3,9 @@ import { type Optional, trySafe } from '@silverhand/essentials';
|
||||||
import { type ZodType, z } from 'zod';
|
import { type ZodType, z } from 'zod';
|
||||||
|
|
||||||
import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js';
|
import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
import { type CacheStore } from './types.js';
|
import { type CacheStore } from './types.js';
|
||||||
|
import { cacheConsole } from './utils.js';
|
||||||
|
|
||||||
type WellKnownMap = {
|
type WellKnownMap = {
|
||||||
sie: SignInExperience;
|
sie: SignInExperience;
|
||||||
|
@ -177,7 +177,7 @@ export class WellKnownCache {
|
||||||
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
|
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
|
||||||
|
|
||||||
if (cachedValue) {
|
if (cachedValue) {
|
||||||
consoleLog.info('[CACHE] Well-known cache hit for', type, promiseKey);
|
cacheConsole.info('Well-known cache hit for', type, promiseKey);
|
||||||
return cachedValue;
|
return cachedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js';
|
import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js';
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
import type { DatabasePool } from '@silverhand/slonik';
|
import type { DatabasePool } from '@silverhand/slonik';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
const consoleLog = new ConsoleLog(chalk.magenta('db-alt'));
|
||||||
|
|
||||||
export const checkAlterationState = async (pool: DatabasePool) => {
|
export const checkAlterationState = async (pool: DatabasePool) => {
|
||||||
const alterations = await getAvailableAlterations(pool);
|
const alterations = await getAvailableAlterations(pool);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { GlobalValues } from '@logto/shared';
|
import { ConsoleLog, GlobalValues } from '@logto/shared';
|
||||||
import type { Optional } from '@silverhand/essentials';
|
import type { Optional } from '@silverhand/essentials';
|
||||||
import { appendPath } from '@silverhand/essentials';
|
import { appendPath } from '@silverhand/essentials';
|
||||||
import type { DatabasePool } from '@silverhand/slonik';
|
import type { DatabasePool } from '@silverhand/slonik';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||||
|
@ -72,11 +73,12 @@ export class EnvSet {
|
||||||
|
|
||||||
this.#pool = pool;
|
this.#pool = pool;
|
||||||
|
|
||||||
|
const consoleLog = new ConsoleLog(chalk.magenta('env-set'));
|
||||||
const { getOidcConfigs } = createLogtoConfigLibrary({
|
const { getOidcConfigs } = createLogtoConfigLibrary({
|
||||||
logtoConfigs: createLogtoConfigQueries(pool),
|
logtoConfigs: createLogtoConfigQueries(pool),
|
||||||
});
|
});
|
||||||
|
|
||||||
const oidcConfigs = await getOidcConfigs();
|
const oidcConfigs = await getOidcConfigs(consoleLog);
|
||||||
const endpoint = customDomain
|
const endpoint = customDomain
|
||||||
? new URL(customDomain)
|
? new URL(customDomain)
|
||||||
: getTenantEndpoint(this.tenantId, EnvSet.values);
|
: getTenantEndpoint(this.tenantId, EnvSet.values);
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import chalk from 'chalk';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
import { grantListener, grantRevocationListener } from './grant.js';
|
import { grantListener, grantRevocationListener } from './grant.js';
|
||||||
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
||||||
import { recordActiveUsers } from './record-active-users.js';
|
import { recordActiveUsers } from './record-active-users.js';
|
||||||
|
|
||||||
|
const consoleLog = new ConsoleLog(chalk.magenta('oidc'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?}
|
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?}
|
||||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events}
|
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events}
|
||||||
|
@ -27,7 +30,7 @@ export const addOidcEventListeners = (provider: Provider, queries: Queries) => {
|
||||||
provider.addListener('interaction.started', interactionStartedListener);
|
provider.addListener('interaction.started', interactionStartedListener);
|
||||||
provider.addListener('interaction.ended', interactionEndedListener);
|
provider.addListener('interaction.ended', interactionEndedListener);
|
||||||
provider.addListener('server_error', (_, error) => {
|
provider.addListener('server_error', (_, error) => {
|
||||||
consoleLog.error('OIDC Provider server_error:', error);
|
consoleLog.error('server_error:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Record token usage.
|
// Record token usage.
|
||||||
|
|
|
@ -1,48 +1,6 @@
|
||||||
import { trySafe } from '@silverhand/essentials';
|
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { findUp } from 'find-up';
|
import { findUp } from 'find-up';
|
||||||
import Koa from 'koa';
|
|
||||||
|
|
||||||
import { checkAlterationState } from './env-set/check-alteration-state.js';
|
|
||||||
import SystemContext from './tenants/SystemContext.js';
|
|
||||||
import { consoleLog } from './utils/console.js';
|
|
||||||
|
|
||||||
dotenv.config({ path: await findUp('.env', {}) });
|
dotenv.config({ path: await findUp('.env', {}) });
|
||||||
|
|
||||||
const { appInsights } = await import('@logto/app-insights/node');
|
await import('./main.js');
|
||||||
|
|
||||||
if (await appInsights.setup('core')) {
|
|
||||||
consoleLog.info('Initialized ApplicationInsights');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import after env has been configured
|
|
||||||
const { loadConnectorFactories } = await import('./utils/connectors/index.js');
|
|
||||||
const { EnvSet } = await import('./env-set/index.js');
|
|
||||||
const { redisCache } = await import('./caches/index.js');
|
|
||||||
const { default: initI18n } = await import('./i18n/init.js');
|
|
||||||
const { tenantPool, checkRowLevelSecurity } = await import('./tenants/index.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const app = new Koa({
|
|
||||||
proxy: EnvSet.values.trustProxyHeader,
|
|
||||||
});
|
|
||||||
const sharedAdminPool = await EnvSet.sharedPool;
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
initI18n(),
|
|
||||||
redisCache.connect(),
|
|
||||||
loadConnectorFactories(),
|
|
||||||
checkRowLevelSecurity(sharedAdminPool),
|
|
||||||
checkAlterationState(sharedAdminPool),
|
|
||||||
SystemContext.shared.loadProviderConfigs(sharedAdminPool),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Import last until init completed
|
|
||||||
const { default: initApp } = await import('./app/init.js');
|
|
||||||
await initApp(app);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
consoleLog.error('Error while initializing app:');
|
|
||||||
consoleLog.error(error);
|
|
||||||
|
|
||||||
void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Hook } from '@logto/schemas';
|
import type { Hook } from '@logto/schemas';
|
||||||
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
import { createMockUtils } from '@logto/shared/esm';
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -73,6 +74,7 @@ describe('triggerInteractionHooks()', () => {
|
||||||
jest.useFakeTimers().setSystemTime(100_000);
|
jest.useFakeTimers().setSystemTime(100_000);
|
||||||
|
|
||||||
await triggerInteractionHooks(
|
await triggerInteractionHooks(
|
||||||
|
new ConsoleLog(),
|
||||||
{ event: InteractionEvent.SignIn, sessionId: 'some_jti', applicationId: 'some_client' },
|
{ event: InteractionEvent.SignIn, sessionId: 'some_jti', applicationId: 'some_client' },
|
||||||
{ userId: '123' }
|
{ userId: '123' }
|
||||||
);
|
);
|
||||||
|
|
|
@ -7,14 +7,13 @@ import {
|
||||||
type HookConfig,
|
type HookConfig,
|
||||||
type HookTestErrorResponseData,
|
type HookTestErrorResponseData,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { type ConsoleLog, generateStandardId } from '@logto/shared';
|
||||||
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
||||||
import { HTTPError } from 'ky';
|
import { HTTPError } from 'ky';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
|
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
|
||||||
|
|
||||||
|
@ -55,6 +54,7 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
const triggerInteractionHooks = async (
|
const triggerInteractionHooks = async (
|
||||||
|
consoleLog: ConsoleLog,
|
||||||
interactionContext: InteractionHookContext,
|
interactionContext: InteractionHookContext,
|
||||||
interactionResult: InteractionHookResult,
|
interactionResult: InteractionHookResult,
|
||||||
userAgent?: string
|
userAgent?: string
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
type JwtCustomizerType,
|
type JwtCustomizerType,
|
||||||
type JwtCustomizerUserContext,
|
type JwtCustomizerUserContext,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
import { type ConsoleLog } from '@logto/shared';
|
||||||
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
|
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
|
|
||||||
|
@ -94,14 +95,17 @@ export const createJwtCustomizerLibrary = (
|
||||||
* @params payload.value - JWT customizer value
|
* @params payload.value - JWT customizer value
|
||||||
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
|
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
|
||||||
*/
|
*/
|
||||||
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(payload: {
|
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
|
||||||
key: T;
|
consoleLog: ConsoleLog,
|
||||||
value: JwtCustomizerType[T];
|
payload: {
|
||||||
useCase: 'test' | 'production';
|
key: T;
|
||||||
}) => {
|
value: JwtCustomizerType[T];
|
||||||
|
useCase: 'test' | 'production';
|
||||||
|
}
|
||||||
|
) => {
|
||||||
const [client, jwtCustomizers] = await Promise.all([
|
const [client, jwtCustomizers] = await Promise.all([
|
||||||
cloudConnection.getClient(),
|
cloudConnection.getClient(),
|
||||||
getJwtCustomizers(),
|
getJwtCustomizers(consoleLog),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
|
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
|
||||||
|
@ -127,10 +131,13 @@ export const createJwtCustomizerLibrary = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(key: T) => {
|
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
|
||||||
|
consoleLog: ConsoleLog,
|
||||||
|
key: T
|
||||||
|
) => {
|
||||||
const [client, jwtCustomizers] = await Promise.all([
|
const [client, jwtCustomizers] = await Promise.all([
|
||||||
cloudConnection.getClient(),
|
cloudConnection.getClient(),
|
||||||
getJwtCustomizers(),
|
getJwtCustomizers(consoleLog),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
|
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
|
||||||
|
|
|
@ -8,12 +8,12 @@ import {
|
||||||
jwtCustomizerConfigGuard,
|
jwtCustomizerConfigGuard,
|
||||||
logtoOidcConfigGuard,
|
logtoOidcConfigGuard,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
import { type ConsoleLog } from '@logto/shared';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { ZodError, z } from 'zod';
|
import { ZodError, z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>;
|
export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>;
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ export const createLogtoConfigLibrary = ({
|
||||||
upsertJwtCustomizer: queryUpsertJwtCustomizer,
|
upsertJwtCustomizer: queryUpsertJwtCustomizer,
|
||||||
},
|
},
|
||||||
}: Pick<Queries, 'logtoConfigs'>) => {
|
}: Pick<Queries, 'logtoConfigs'>) => {
|
||||||
const getOidcConfigs = async (): Promise<LogtoOidcConfigType> => {
|
const getOidcConfigs = async (consoleLog: ConsoleLog): Promise<LogtoOidcConfigType> => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey));
|
const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey));
|
||||||
|
|
||||||
|
@ -96,7 +96,7 @@ export const createLogtoConfigLibrary = ({
|
||||||
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value;
|
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getJwtCustomizers = async (): Promise<Partial<JwtCustomizerType>> => {
|
const getJwtCustomizers = async (consoleLog: ConsoleLog): Promise<Partial<JwtCustomizerType>> => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey));
|
const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey));
|
||||||
|
|
||||||
|
|
44
packages/core/src/main.ts
Normal file
44
packages/core/src/main.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import { trySafe } from '@silverhand/essentials';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import Koa from 'koa';
|
||||||
|
|
||||||
|
import { redisCache } from './caches/index.js';
|
||||||
|
import { checkAlterationState } from './env-set/check-alteration-state.js';
|
||||||
|
import { EnvSet } from './env-set/index.js';
|
||||||
|
import initI18n from './i18n/init.js';
|
||||||
|
import SystemContext from './tenants/SystemContext.js';
|
||||||
|
import { checkRowLevelSecurity, tenantPool } from './tenants/index.js';
|
||||||
|
import { loadConnectorFactories } from './utils/connectors/index.js';
|
||||||
|
|
||||||
|
const { appInsights } = await import('@logto/app-insights/node');
|
||||||
|
const consoleLog = new ConsoleLog(chalk.magenta('index'));
|
||||||
|
|
||||||
|
if (await appInsights.setup('core')) {
|
||||||
|
consoleLog.info('Initialized ApplicationInsights');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = new Koa({
|
||||||
|
proxy: EnvSet.values.trustProxyHeader,
|
||||||
|
});
|
||||||
|
const sharedAdminPool = await EnvSet.sharedPool;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
initI18n(),
|
||||||
|
redisCache.connect(),
|
||||||
|
loadConnectorFactories(),
|
||||||
|
checkRowLevelSecurity(sharedAdminPool),
|
||||||
|
checkAlterationState(sharedAdminPool),
|
||||||
|
SystemContext.shared.loadProviderConfigs(sharedAdminPool),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Import last until init completed
|
||||||
|
const { default: initApp } = await import('./app/init.js');
|
||||||
|
await initApp(app);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
consoleLog.error('Error while initializing app:');
|
||||||
|
consoleLog.error(error);
|
||||||
|
|
||||||
|
void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]);
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import { z } from 'zod';
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { devConsole } from '#src/utils/console.js';
|
||||||
|
|
||||||
import { getAdminTenantTokenValidationSet } from './utils.js';
|
import { getAdminTenantTokenValidationSet } from './utils.js';
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ export const verifyBearerTokenFromRequest = async (
|
||||||
if ((!isProduction || isIntegrationTest) && userId) {
|
if ((!isProduction || isIntegrationTest) && userId) {
|
||||||
// This log is distracting in integration tests.
|
// This log is distracting in integration tests.
|
||||||
if (!isIntegrationTest) {
|
if (!isIntegrationTest) {
|
||||||
consoleLog.warn(`Found dev user ID ${userId}, skip token validation.`);
|
devConsole.warn(`Found dev user ID ${userId}, skip token validation.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -5,14 +5,22 @@ import { HttpError } from 'koa';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
import { buildAppInsightsTelemetry } from '#src/utils/request.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The middleware to handle errors.
|
||||||
|
*
|
||||||
|
* Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`).
|
||||||
|
*/
|
||||||
export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
|
export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
|
||||||
StateT,
|
StateT,
|
||||||
ContextT,
|
ContextT,
|
||||||
BodyT | RequestErrorBody | { message: string }
|
BodyT | RequestErrorBody | { message: string }
|
||||||
> {
|
> {
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
|
const consoleLog = getConsoleLogFromContext(ctx);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await next();
|
await next();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
@ -21,7 +29,7 @@ export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report all exceptions to ApplicationInsights
|
// Report all exceptions to ApplicationInsights
|
||||||
void appInsights.trackException(error);
|
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
|
||||||
|
|
||||||
if (error instanceof RequestError) {
|
if (error instanceof RequestError) {
|
||||||
ctx.status = error.status;
|
ctx.status = error.status;
|
||||||
|
|
|
@ -9,7 +9,8 @@ import type { ZodType, ZodTypeDef } from 'zod';
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { ResponseBodyError, StatusCodeError } from '#src/errors/ServerError/index.js';
|
import { ResponseBodyError, StatusCodeError } from '#src/errors/ServerError/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
import { buildAppInsightsTelemetry } from '#src/utils/request.js';
|
||||||
|
|
||||||
/** Configure what and how to guard. */
|
/** Configure what and how to guard. */
|
||||||
export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
||||||
|
@ -121,6 +122,11 @@ const tryParse = <Output, Definition extends ZodTypeDef, Input>(
|
||||||
return parse(type, guard, data);
|
return parse(type, guard, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard middleware factory for request and response.
|
||||||
|
*
|
||||||
|
* Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`).
|
||||||
|
*/
|
||||||
export default function koaGuard<
|
export default function koaGuard<
|
||||||
StateT,
|
StateT,
|
||||||
ContextT extends IRouterParamContext,
|
ContextT extends IRouterParamContext,
|
||||||
|
@ -170,6 +176,8 @@ export default function koaGuard<
|
||||||
GuardResponseT
|
GuardResponseT
|
||||||
>
|
>
|
||||||
> = async function (ctx, next) {
|
> = async function (ctx, next) {
|
||||||
|
const consoleLog = getConsoleLogFromContext(ctx);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert the status code matches the value(s) in the config. If the config does not
|
* Assert the status code matches the value(s) in the config. If the config does not
|
||||||
* specify a status code, it will not assert anything.
|
* specify a status code, it will not assert anything.
|
||||||
|
@ -191,7 +199,10 @@ export default function koaGuard<
|
||||||
|
|
||||||
if (EnvSet.values.isProduction) {
|
if (EnvSet.values.isProduction) {
|
||||||
consoleLog.warn('Unexpected status code:', value, 'expected:', status);
|
consoleLog.warn('Unexpected status code:', value, 'expected:', status);
|
||||||
void appInsights.trackException(new StatusCodeError(status, value));
|
void appInsights.trackException(
|
||||||
|
new StatusCodeError(status, value),
|
||||||
|
buildAppInsightsTelemetry(ctx)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { errors } from 'oidc-provider';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supplementary URIs for oidc-provider errors.
|
* Supplementary URIs for oidc-provider errors.
|
||||||
|
@ -18,6 +18,8 @@ const errorUris: Record<string, string> = Object.freeze({
|
||||||
* Transform oidc-provider error to a format for the client. This is edited from oidc-provider's
|
* Transform oidc-provider error to a format for the client. This is edited from oidc-provider's
|
||||||
* own implementation.
|
* own implementation.
|
||||||
*
|
*
|
||||||
|
* Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`).
|
||||||
|
*
|
||||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/37d0a6cfb3c618141a44cbb904ce45659438f821/lib/helpers/err_out.js | oidc-provider/lib/helpers/err_out.js}
|
* @see {@link https://github.com/panva/node-oidc-provider/blob/37d0a6cfb3c618141a44cbb904ce45659438f821/lib/helpers/err_out.js | oidc-provider/lib/helpers/err_out.js}
|
||||||
*/
|
*/
|
||||||
export const errorOut = ({
|
export const errorOut = ({
|
||||||
|
@ -92,7 +94,7 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
|
||||||
ctx.body = errorOut(error);
|
ctx.body = errorOut(error);
|
||||||
|
|
||||||
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) {
|
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) {
|
||||||
consoleLog.error(error);
|
getConsoleLogFromContext(ctx).error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { defaults, parseAffiliateData } from '@logto/affiliate';
|
import { defaults, parseAffiliateData } from '@logto/affiliate';
|
||||||
import { consoleLog } from '@logto/cli/lib/utils.js';
|
|
||||||
import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
|
import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
|
||||||
import { conditional, trySafe } from '@silverhand/essentials';
|
import { conditional, trySafe } from '@silverhand/essentials';
|
||||||
import { type IRouterContext } from 'koa-router';
|
import { type IRouterContext } from 'koa-router';
|
||||||
|
@ -10,6 +9,7 @@ import { type ConnectorLibrary } from '#src/libraries/connector.js';
|
||||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
import { type OmitAutoSetFields } from '#src/utils/sql.js';
|
import { type OmitAutoSetFields } from '#src/utils/sql.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -146,6 +146,6 @@ export const postAffiliateLogs = async (
|
||||||
await client.post('/api/affiliate-logs', {
|
await client.post('/api/affiliate-logs', {
|
||||||
body: { userId, ...affiliateData },
|
body: { userId, ...affiliateData },
|
||||||
});
|
});
|
||||||
consoleLog.info('Affiliate logs posted', userId);
|
getConsoleLogFromContext(ctx).info('Affiliate logs posted', userId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -24,7 +24,8 @@ import { assignInteractionResults } from '#src/libraries/session.js';
|
||||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||||
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
|
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
import { buildAppInsightsTelemetry } from '#src/utils/request.js';
|
||||||
import { getTenantId } from '#src/utils/tenant.js';
|
import { getTenantId } from '#src/utils/tenant.js';
|
||||||
|
|
||||||
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
||||||
|
@ -180,8 +181,8 @@ async function handleSubmitRegister(
|
||||||
log?.append({ userId: id });
|
log?.append({ userId: id });
|
||||||
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
|
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
|
||||||
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
|
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
|
||||||
consoleLog.warn('Failed to post affiliate logs', error);
|
getConsoleLogFromContext(ctx).warn('Failed to post affiliate logs', error);
|
||||||
void appInsights.trackException(error);
|
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
type InteractionHookResult,
|
type InteractionHookResult,
|
||||||
} from '#src/libraries/hook/index.js';
|
} from '#src/libraries/hook/index.js';
|
||||||
import type Libraries from '#src/tenants/Libraries.js';
|
import type Libraries from '#src/tenants/Libraries.js';
|
||||||
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
|
||||||
import { getInteractionStorage } from '../utils/interaction.js';
|
import { getInteractionStorage } from '../utils/interaction.js';
|
||||||
|
|
||||||
|
@ -64,7 +65,12 @@ export default function koaInteractionHooks<
|
||||||
if (interactionHookResult) {
|
if (interactionHookResult) {
|
||||||
// Hooks should not crash the app
|
// Hooks should not crash the app
|
||||||
void trySafe(
|
void trySafe(
|
||||||
triggerInteractionHooks(interactionHookContext, interactionHookResult, userAgent)
|
triggerInteractionHooks(
|
||||||
|
getConsoleLogFromContext(ctx),
|
||||||
|
interactionHookContext,
|
||||||
|
interactionHookResult,
|
||||||
|
userAgent
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
import { exportJWK } from '#src/utils/jwks.js';
|
import { exportJWK } from '#src/utils/jwks.js';
|
||||||
|
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
@ -104,7 +105,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { keyType } = ctx.guard.params;
|
const { keyType } = ctx.guard.params;
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
const configs = await getOidcConfigs();
|
const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
|
||||||
|
|
||||||
// Remove actual values of the private keys from response
|
// Remove actual values of the private keys from response
|
||||||
ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]);
|
ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]);
|
||||||
|
@ -125,7 +126,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { keyType, keyId } = ctx.guard.params;
|
const { keyType, keyId } = ctx.guard.params;
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
const configs = await getOidcConfigs();
|
const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
|
||||||
const existingKeys = configs[configKey];
|
const existingKeys = configs[configKey];
|
||||||
|
|
||||||
if (existingKeys.length <= 1) {
|
if (existingKeys.length <= 1) {
|
||||||
|
@ -163,7 +164,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
|
||||||
const { keyType } = ctx.guard.params;
|
const { keyType } = ctx.guard.params;
|
||||||
const { signingKeyAlgorithm } = ctx.guard.body;
|
const { signingKeyAlgorithm } = ctx.guard.body;
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
const configs = await getOidcConfigs();
|
const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
|
||||||
const existingKeys = configs[configKey];
|
const existingKeys = configs[configKey];
|
||||||
|
|
||||||
const newPrivateKey =
|
const newPrivateKey =
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
LogtoJwtTokenKeyType,
|
LogtoJwtTokenKeyType,
|
||||||
type JwtCustomizerTestRequestBody,
|
type JwtCustomizerTestRequestBody,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
import { pickDefault } from '@logto/shared/esm';
|
import { pickDefault } from '@logto/shared/esm';
|
||||||
import { pick } from '@silverhand/essentials';
|
import { pick } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
@ -60,11 +61,14 @@ describe('configs JWT customizer routes', () => {
|
||||||
.put(`/configs/jwt-customizer/access-token`)
|
.put(`/configs/jwt-customizer/access-token`)
|
||||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
|
||||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||||
key: LogtoJwtTokenKey.AccessToken,
|
expect.any(ConsoleLog),
|
||||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
{
|
||||||
useCase: 'production',
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
});
|
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||||
|
useCase: 'production',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith(
|
expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith(
|
||||||
LogtoJwtTokenKey.AccessToken,
|
LogtoJwtTokenKey.AccessToken,
|
||||||
|
@ -102,11 +106,14 @@ describe('configs JWT customizer routes', () => {
|
||||||
.patch('/configs/jwt-customizer/access-token')
|
.patch('/configs/jwt-customizer/access-token')
|
||||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
|
||||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||||
key: LogtoJwtTokenKey.AccessToken,
|
expect.any(ConsoleLog),
|
||||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
{
|
||||||
useCase: 'production',
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
});
|
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||||
|
useCase: 'production',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith(
|
expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith(
|
||||||
LogtoJwtTokenKey.AccessToken,
|
LogtoJwtTokenKey.AccessToken,
|
||||||
|
@ -141,6 +148,7 @@ describe('configs JWT customizer routes', () => {
|
||||||
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
|
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
|
||||||
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
|
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
|
||||||
expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith(
|
expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||||
|
expect.any(ConsoleLog),
|
||||||
LogtoJwtTokenKey.ClientCredentials
|
LogtoJwtTokenKey.ClientCredentials
|
||||||
);
|
);
|
||||||
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
|
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
|
||||||
|
@ -163,11 +171,14 @@ describe('configs JWT customizer routes', () => {
|
||||||
|
|
||||||
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
|
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
|
||||||
|
|
||||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||||
key: LogtoJwtTokenKey.ClientCredentials,
|
expect.any(ConsoleLog),
|
||||||
value: payload,
|
{
|
||||||
useCase: 'test',
|
key: LogtoJwtTokenKey.ClientCredentials,
|
||||||
});
|
value: payload,
|
||||||
|
useCase: 'test',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
|
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
|
import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
|
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
|
||||||
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
|
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
|
||||||
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
|
||||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
@ -78,7 +79,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
||||||
|
|
||||||
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
|
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
|
||||||
if (!isIntegrationTest) {
|
if (!isIntegrationTest) {
|
||||||
await deployJwtCustomizerScript({
|
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
|
||||||
key,
|
key,
|
||||||
value: body,
|
value: body,
|
||||||
useCase: 'production',
|
useCase: 'production',
|
||||||
|
@ -122,7 +123,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
||||||
|
|
||||||
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
|
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
|
||||||
if (!isIntegrationTest) {
|
if (!isIntegrationTest) {
|
||||||
await deployJwtCustomizerScript({
|
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
|
||||||
key,
|
key,
|
||||||
value: body,
|
value: body,
|
||||||
useCase: 'production',
|
useCase: 'production',
|
||||||
|
@ -142,7 +143,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
||||||
status: [200],
|
status: [200],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const jwtCustomizer = await getJwtCustomizers();
|
const jwtCustomizer = await getJwtCustomizers(getConsoleLogFromContext(ctx));
|
||||||
ctx.body = Object.values(LogtoJwtTokenKey)
|
ctx.body = Object.values(LogtoJwtTokenKey)
|
||||||
.filter((key) => jwtCustomizer[key])
|
.filter((key) => jwtCustomizer[key])
|
||||||
.map((key) => ({ key, value: jwtCustomizer[key] }));
|
.map((key) => ({ key, value: jwtCustomizer[key] }));
|
||||||
|
@ -194,7 +195,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
||||||
|
|
||||||
// Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
|
// Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
|
||||||
if (!isIntegrationTest) {
|
if (!isIntegrationTest) {
|
||||||
await undeployJwtCustomizerScript(tokenKey);
|
await undeployJwtCustomizerScript(getConsoleLogFromContext(ctx), tokenKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteJwtCustomizer(tokenKey);
|
await deleteJwtCustomizer(tokenKey);
|
||||||
|
@ -219,7 +220,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
|
||||||
const { body } = ctx.guard;
|
const { body } = ctx.guard;
|
||||||
|
|
||||||
// Deploy the test script
|
// Deploy the test script
|
||||||
await deployJwtCustomizerScript({
|
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
|
||||||
key:
|
key:
|
||||||
body.tokenType === LogtoJwtTokenKeyType.AccessToken
|
body.tokenType === LogtoJwtTokenKeyType.AccessToken
|
||||||
? LogtoJwtTokenKey.AccessToken
|
? LogtoJwtTokenKey.AccessToken
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { isGuardMiddleware } from '#src/middleware/koa-guard.js';
|
||||||
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
|
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
|
||||||
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
|
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
|
||||||
|
|
||||||
import type { AnonymousRouter } from '../types.js';
|
import type { AnonymousRouter } from '../types.js';
|
||||||
|
@ -241,7 +241,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
);
|
);
|
||||||
|
|
||||||
if (EnvSet.values.isUnitTest) {
|
if (EnvSet.values.isUnitTest) {
|
||||||
consoleLog.warn('Skip validating swagger document in unit test.');
|
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
||||||
}
|
}
|
||||||
// Don't throw for integrity check in production as it has no benefit.
|
// Don't throw for integrity check in production as it has no benefit.
|
||||||
else if (!EnvSet.values.isProduction || EnvSet.values.isIntegrationTest) {
|
else if (!EnvSet.values.isProduction || EnvSet.values.isIntegrationTest) {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
import { type DeepPartial } from '#src/test-utils/tenant.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { devConsole } from '#src/utils/console.js';
|
||||||
|
|
||||||
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
|
@ -166,7 +166,7 @@ export const validateSupplement = (
|
||||||
export const validateSwaggerDocument = (document: OpenAPIV3.Document) => {
|
export const validateSwaggerDocument = (document: OpenAPIV3.Document) => {
|
||||||
for (const [path, operations] of Object.entries(document.paths)) {
|
for (const [path, operations] of Object.entries(document.paths)) {
|
||||||
if (path.startsWith('/api/interaction')) {
|
if (path.startsWith('/api/interaction')) {
|
||||||
consoleLog.warn(`Path \`${path}\` is not documented. Do something!`);
|
devConsole.warn(`Path \`${path}\` is not documented. Do something!`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import SystemContext from '#src/tenants/SystemContext.js';
|
import SystemContext from '#src/tenants/SystemContext.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
import { uploadFileGuard } from '#src/utils/storage/consts.js';
|
import { uploadFileGuard } from '#src/utils/storage/consts.js';
|
||||||
import { buildUploadFile } from '#src/utils/storage/index.js';
|
import { buildUploadFile } from '#src/utils/storage/index.js';
|
||||||
import { getTenantId } from '#src/utils/tenant.js';
|
import { getTenantId } from '#src/utils/tenant.js';
|
||||||
|
@ -92,7 +92,7 @@ export default function userAssetsRoutes<T extends ManagementApiRouter>(
|
||||||
|
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
consoleLog.error(error);
|
getConsoleLogFromContext(ctx).error(error);
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
code: 'storage.upload_error',
|
code: 'storage.upload_error',
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
|
@ -13,7 +13,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
|
||||||
import { type ZodType } from 'zod';
|
import { type ZodType } from 'zod';
|
||||||
|
|
||||||
import { createSystemsQuery } from '#src/queries/system.js';
|
import { createSystemsQuery } from '#src/queries/system.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { devConsole } from '#src/utils/console.js';
|
||||||
|
|
||||||
export default class SystemContext {
|
export default class SystemContext {
|
||||||
static shared = new SystemContext();
|
static shared = new SystemContext();
|
||||||
|
@ -70,7 +70,7 @@ export default class SystemContext {
|
||||||
const result = guard.safeParse(record.value);
|
const result = guard.safeParse(record.value);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
consoleLog.error(`Failed to parse ${key} config:`, result.error);
|
devConsole.error(`Failed to parse ${key} config:`, result.error);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,7 @@ describe('Tenant', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call middleware factories for user tenants', async () => {
|
it('should call middleware factories for user tenants', async () => {
|
||||||
await Tenant.create(defaultTenantId, new RedisCache());
|
await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() });
|
||||||
|
|
||||||
for (const [, middleware, shouldCall] of userMiddlewareList) {
|
for (const [, middleware, shouldCall] of userMiddlewareList) {
|
||||||
if (shouldCall) {
|
if (shouldCall) {
|
||||||
|
@ -66,7 +66,7 @@ describe('Tenant', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call middleware factories for the admin tenant', async () => {
|
it('should call middleware factories for the admin tenant', async () => {
|
||||||
await Tenant.create(adminTenantId, new RedisCache());
|
await Tenant.create({ id: adminTenantId, redisCache: new RedisCache() });
|
||||||
|
|
||||||
for (const [, middleware, shouldCall] of adminMiddlewareList) {
|
for (const [, middleware, shouldCall] of adminMiddlewareList) {
|
||||||
if (shouldCall) {
|
if (shouldCall) {
|
||||||
|
@ -80,7 +80,7 @@ describe('Tenant', () => {
|
||||||
|
|
||||||
describe('Tenant `.run()`', () => {
|
describe('Tenant `.run()`', () => {
|
||||||
it('should return a function ', async () => {
|
it('should return a function ', async () => {
|
||||||
const tenant = await Tenant.create(defaultTenantId, new RedisCache());
|
const tenant = await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() });
|
||||||
expect(typeof tenant.run).toBe('function');
|
expect(typeof tenant.run).toBe('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -88,7 +88,7 @@ describe('Tenant `.run()`', () => {
|
||||||
describe('Tenant cache health check', () => {
|
describe('Tenant cache health check', () => {
|
||||||
it('should set expiration timestamp in redis', async () => {
|
it('should set expiration timestamp in redis', async () => {
|
||||||
const redisCache = new RedisCache();
|
const redisCache = new RedisCache();
|
||||||
const tenant = await Tenant.create(defaultTenantId, redisCache);
|
const tenant = await Tenant.create({ id: defaultTenantId, redisCache });
|
||||||
expect(typeof tenant.invalidateCache).toBe('function');
|
expect(typeof tenant.invalidateCache).toBe('function');
|
||||||
|
|
||||||
Sinon.stub(tenant.wellKnownCache, 'set').value(jest.fn());
|
Sinon.stub(tenant.wellKnownCache, 'set').value(jest.fn());
|
||||||
|
@ -102,7 +102,7 @@ describe('Tenant cache health check', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to check the health of tenant cache', async () => {
|
it('should be able to check the health of tenant cache', async () => {
|
||||||
const tenant = await Tenant.create(defaultTenantId, new RedisCache());
|
const tenant = await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() });
|
||||||
expect(typeof tenant.checkHealth).toBe('function');
|
expect(typeof tenant.checkHealth).toBe('function');
|
||||||
expect(await tenant.checkHealth()).toBe(true);
|
expect(await tenant.checkHealth()).toBe(true);
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import type { MiddlewareType } from 'koa';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import compose from 'koa-compose';
|
import compose from 'koa-compose';
|
||||||
import koaCompress from 'koa-compress';
|
import koaCompress from 'koa-compress';
|
||||||
import koaLogger from 'koa-logger';
|
|
||||||
import mount from 'koa-mount';
|
import mount from 'koa-mount';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
|
@ -34,8 +33,18 @@ import Queries from './Queries.js';
|
||||||
import type TenantContext from './TenantContext.js';
|
import type TenantContext from './TenantContext.js';
|
||||||
import { getTenantDatabaseDsn } from './utils.js';
|
import { getTenantDatabaseDsn } from './utils.js';
|
||||||
|
|
||||||
|
/** Data for creating a tenant instance. */
|
||||||
|
type CreateTenant = {
|
||||||
|
/** The unique identifier of the tenant. */
|
||||||
|
id: string;
|
||||||
|
/** The cache store for the tenant. */
|
||||||
|
redisCache: CacheStore;
|
||||||
|
/** The custom domain of the tenant, if applicable. */
|
||||||
|
customDomain?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default class Tenant implements TenantContext {
|
export default class Tenant implements TenantContext {
|
||||||
static async create(id: string, redisCache: CacheStore, customDomain?: string): Promise<Tenant> {
|
static async create({ id, redisCache, customDomain }: CreateTenant): Promise<Tenant> {
|
||||||
// Treat the default database URL as the management URL
|
// Treat the default database URL as the management URL
|
||||||
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
|
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
|
||||||
// Custom endpoint is used for building OIDC issuer URL when the request is a custom domain
|
// Custom endpoint is used for building OIDC issuer URL when the request is a custom domain
|
||||||
|
@ -82,7 +91,6 @@ export default class Tenant implements TenantContext {
|
||||||
// Init app
|
// Init app
|
||||||
const app = new Koa();
|
const app = new Koa();
|
||||||
|
|
||||||
app.use(koaLogger());
|
|
||||||
app.use(koaErrorHandler());
|
app.use(koaErrorHandler());
|
||||||
app.use(koaOidcErrorHandler());
|
app.use(koaOidcErrorHandler());
|
||||||
app.use(koaSlonikErrorHandler());
|
app.use(koaSlonikErrorHandler());
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import chalk from 'chalk';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
import { redisCache } from '#src/caches/index.js';
|
import { redisCache } from '#src/caches/index.js';
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
|
||||||
import Tenant from './Tenant.js';
|
import Tenant from './Tenant.js';
|
||||||
|
|
||||||
export class TenantPool {
|
const consoleLog = new ConsoleLog(chalk.magenta('tenant'));
|
||||||
|
|
||||||
|
class TenantPool {
|
||||||
protected cache = new LRUCache<string, Promise<Tenant>>({
|
protected cache = new LRUCache<string, Promise<Tenant>>({
|
||||||
max: EnvSet.values.tenantPoolSize,
|
max: EnvSet.values.tenantPoolSize,
|
||||||
dispose: async (entry) => {
|
dispose: async (entry) => {
|
||||||
|
@ -29,7 +32,7 @@ export class TenantPool {
|
||||||
}
|
}
|
||||||
|
|
||||||
consoleLog.info('Init tenant:', tenantId, customDomain);
|
consoleLog.info('Init tenant:', tenantId, customDomain);
|
||||||
const newTenantPromise = Tenant.create(tenantId, redisCache, customDomain);
|
const newTenantPromise = Tenant.create({ id: tenantId, redisCache, customDomain });
|
||||||
this.cache.set(cacheKey, newTenantPromise);
|
this.cache.set(cacheKey, newTenantPromise);
|
||||||
|
|
||||||
return newTenantPromise;
|
return newTenantPromise;
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import { parseJson } from '@logto/connector-kit';
|
import { parseJson } from '@logto/connector-kit';
|
||||||
import { type CloudflareData, DomainStatus } from '@logto/schemas';
|
import { type CloudflareData, DomainStatus } from '@logto/schemas';
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import chalk from 'chalk';
|
||||||
import { type Response } from 'got';
|
import { type Response } from 'got';
|
||||||
import { type ZodType } from 'zod';
|
import { type ZodType } from 'zod';
|
||||||
|
|
||||||
import assertThat from '../assert-that.js';
|
import assertThat from '../assert-that.js';
|
||||||
import { consoleLog } from '../console.js';
|
|
||||||
|
|
||||||
import { type HandleResponse, cloudflareResponseGuard } from './types.js';
|
import { type HandleResponse, cloudflareResponseGuard } from './types.js';
|
||||||
|
|
||||||
|
const consoleLog = new ConsoleLog(chalk.magenta('cf'));
|
||||||
|
|
||||||
const parseCloudflareResponse = (body: string) => {
|
const parseCloudflareResponse = (body: string) => {
|
||||||
const result = cloudflareResponseGuard.safeParse(parseJson(body));
|
const result = cloudflareResponseGuard.safeParse(parseJson(body));
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
|
||||||
import { type LogtoConnector } from './types.js';
|
import { type LogtoConnector } from './types.js';
|
||||||
|
|
||||||
export const isPasswordlessLogtoConnector = (
|
const isPasswordlessLogtoConnector = (
|
||||||
connector: LogtoConnector
|
connector: LogtoConnector
|
||||||
): connector is LogtoConnector<EmailConnector | SmsConnector> =>
|
): connector is LogtoConnector<EmailConnector | SmsConnector> =>
|
||||||
connector.type !== ConnectorType.Social;
|
connector.type !== ConnectorType.Social;
|
||||||
|
|
47
packages/core/src/utils/console.test.ts
Normal file
47
packages/core/src/utils/console.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
import { SilentConsoleLog, getConsoleLogFromContext, unknownConsole } from './console.js';
|
||||||
|
|
||||||
|
describe('console', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
Sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConsoleLogFromContext', () => {
|
||||||
|
it('should return the console log from the context', () => {
|
||||||
|
const context = {
|
||||||
|
console: new ConsoleLog('test'),
|
||||||
|
};
|
||||||
|
const result = getConsoleLogFromContext(context);
|
||||||
|
|
||||||
|
expect(result).toBe(context.console);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if the context does not have a console log in development', () => {
|
||||||
|
Sinon.stub(EnvSet, 'values').get(() => ({ isProduction: false, isUnitTest: false }));
|
||||||
|
const context = {};
|
||||||
|
const act = () => getConsoleLogFromContext(context);
|
||||||
|
|
||||||
|
expect(act).toThrowError(
|
||||||
|
'Failed to get console log from context, please provide a valid context.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a silent console log in unit test', () => {
|
||||||
|
const context = {};
|
||||||
|
const result = getConsoleLogFromContext(context);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(SilentConsoleLog);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the unknown console log in production', () => {
|
||||||
|
Sinon.stub(EnvSet, 'values').get(() => ({ isProduction: true, isUnitTest: false }));
|
||||||
|
const context = {};
|
||||||
|
const result = getConsoleLogFromContext(context);
|
||||||
|
expect(result).toBe(unknownConsole);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,3 +1,56 @@
|
||||||
import { ConsoleLog } from '@logto/shared';
|
import { ConsoleLog } from '@logto/shared';
|
||||||
|
import { noop } from '@silverhand/essentials';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
export const consoleLog: ConsoleLog = new ConsoleLog();
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
export class SilentConsoleLog extends ConsoleLog {
|
||||||
|
plain = noop;
|
||||||
|
info = noop;
|
||||||
|
succeed = noop;
|
||||||
|
warn = noop;
|
||||||
|
error = noop;
|
||||||
|
fatal = () => {
|
||||||
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fallback console log with `unknown` prefix. */
|
||||||
|
export const unknownConsole: ConsoleLog = new ConsoleLog(chalk.yellow('unknown'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The development console log with `dev` prefix. Usually you should use context-aware console log
|
||||||
|
* instead of this.
|
||||||
|
*/
|
||||||
|
export const devConsole: ConsoleLog = new ConsoleLog(chalk.magenta('dev'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to get the `ConsoleLog` instance from the context by checking if the `console` property is
|
||||||
|
* an instance of `ConsoleLog`. If it is not:
|
||||||
|
*
|
||||||
|
* - In production, return the default console log with `unknown` prefix.
|
||||||
|
* - In development or testing, throw an error.
|
||||||
|
*
|
||||||
|
* The in-context console log is used to provide a more context-aware logging experience.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types -- We need to accept any object as context
|
||||||
|
export const getConsoleLogFromContext = (context: object): ConsoleLog => {
|
||||||
|
if ('console' in context && context.console instanceof ConsoleLog) {
|
||||||
|
return context.console;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production or unit testing, we should safely return an instance of `ConsoleLog`
|
||||||
|
if (!EnvSet.values.isProduction && !EnvSet.values.isUnitTest) {
|
||||||
|
throw new Error('Failed to get console log from context, please provide a valid context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnvSet.values.isUnitTest) {
|
||||||
|
return new SilentConsoleLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
unknownConsole.warn(
|
||||||
|
'Failed to get console log from context, returning the unknown-prefixed `ConsoleLog`.'
|
||||||
|
);
|
||||||
|
return unknownConsole;
|
||||||
|
};
|
||||||
|
|
21
packages/core/src/utils/request.ts
Normal file
21
packages/core/src/utils/request.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { type ExceptionTelemetry } from '@logto/app-insights/node';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
const getRequestIdFromContext = (context: object): string | undefined => {
|
||||||
|
if ('requestId' in context && typeof context.requestId === 'string') {
|
||||||
|
return context.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
export const buildAppInsightsTelemetry = (context: object): Partial<ExceptionTelemetry> => {
|
||||||
|
const requestId = getRequestIdFromContext(context);
|
||||||
|
|
||||||
|
if (requestId) {
|
||||||
|
return { properties: { requestId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
|
@ -6,7 +6,8 @@ import { type CommonQueryMethods } from '@silverhand/slonik';
|
||||||
import { redisCache } from '#src/caches/index.js';
|
import { redisCache } from '#src/caches/index.js';
|
||||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||||
import { createDomainsQueries } from '#src/queries/domains.js';
|
import { createDomainsQueries } from '#src/queries/domains.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
|
||||||
|
import { devConsole } from './console.js';
|
||||||
|
|
||||||
const normalizePathname = (pathname: string) =>
|
const normalizePathname = (pathname: string) =>
|
||||||
pathname + conditionalString(!pathname.endsWith('/') && '/');
|
pathname + conditionalString(!pathname.endsWith('/') && '/');
|
||||||
|
@ -105,7 +106,7 @@ export const getTenantId = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!isProduction || isIntegrationTest) && developmentTenantId) {
|
if ((!isProduction || isIntegrationTest) && developmentTenantId) {
|
||||||
consoleLog.warn(`Found dev tenant ID ${developmentTenantId}.`);
|
devConsole.warn(`Found dev tenant ID ${developmentTenantId}.`);
|
||||||
|
|
||||||
return [developmentTenantId, false];
|
return [developmentTenantId, false];
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,4 +4,9 @@ describe('health check', () => {
|
||||||
it('should have a health state', async () => {
|
it('should have a health state', async () => {
|
||||||
expect(await api.get('status')).toHaveProperty('status', 204);
|
expect(await api.get('status')).toHaveProperty('status', 204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return request id in headers', async () => {
|
||||||
|
const { headers } = await api.get('status');
|
||||||
|
expect(headers.has('logto-core-request-id')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,6 +18,11 @@ describe('.well-known api', () => {
|
||||||
expect(response instanceof HTTPError && response.response.status === 404).toBe(true);
|
expect(response instanceof HTTPError && response.response.status === 404).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return request id in headers', async () => {
|
||||||
|
const { headers } = await adminTenantApi.get(`.well-known/endpoints/123`);
|
||||||
|
expect(headers.has('logto-core-request-id')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('get /.well-known/sign-in-exp for console', async () => {
|
it('get /.well-known/sign-in-exp for console', async () => {
|
||||||
const response = await adminTenantApi.get('.well-known/sign-in-exp').json<SignInExperience>();
|
const response = await adminTenantApi.get('.well-known/sign-in-exp').json<SignInExperience>();
|
||||||
|
|
||||||
|
|
87
packages/shared/src/node/env/ConsoleLog.test.ts
vendored
Normal file
87
packages/shared/src/node/env/ConsoleLog.test.ts
vendored
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { noop } from '@silverhand/essentials';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import ConsoleLog from './ConsoleLog.js';
|
||||||
|
|
||||||
|
describe('ConsoleLog', () => {
|
||||||
|
it('logs the plain message as is', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog().plain('message', 1, undefined, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('message', 1, undefined, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the info message with an info prefix', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog().info('message', 1, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/info/), 'message', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the success message with an info and checkmark prefix', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog().succeed('message', 1, undefined, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/info/),
|
||||||
|
expect.stringMatching(/✔/),
|
||||||
|
'message',
|
||||||
|
1,
|
||||||
|
undefined,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the warn message with a warn prefix', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(noop);
|
||||||
|
new ConsoleLog().warn('message', { a: 1 });
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/warn/), 'message', { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the error message with a error prefix', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(noop);
|
||||||
|
new ConsoleLog().error('message', { a: 1 });
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/error/), 'message', {
|
||||||
|
a: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the fatal message with a fatal prefix and exits the process', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(noop);
|
||||||
|
// @ts-expect-error process exit is mocked
|
||||||
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(noop);
|
||||||
|
new ConsoleLog().fatal('message', { a: 1 });
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/fatal/), 'message', { a: 1 });
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the message with a custom prefix', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog('custom').plain('message', 1, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('custom ', 'message', 1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the message with a custom prefix and an info prefix', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog('custom').info('message', 1, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'custom ',
|
||||||
|
expect.stringMatching(/info/),
|
||||||
|
'message',
|
||||||
|
1,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the message with a custom prefix and padding', () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(noop);
|
||||||
|
new ConsoleLog('custom', 10).plain('message', 1, null);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('custom ', 'message', 1, null);
|
||||||
|
});
|
||||||
|
});
|
33
packages/shared/src/node/env/ConsoleLog.ts
vendored
33
packages/shared/src/node/env/ConsoleLog.ts
vendored
|
@ -8,10 +8,24 @@ export default class ConsoleLog {
|
||||||
fatal: chalk.bold(chalk.red('fatal')),
|
fatal: chalk.bold(chalk.red('fatal')),
|
||||||
});
|
});
|
||||||
|
|
||||||
plain = console.log;
|
constructor(
|
||||||
|
/** A prefix to prepend to all log messages. */
|
||||||
|
public readonly prefix?: string,
|
||||||
|
/**
|
||||||
|
* The number of spaces to pad the prefix. For example, if the prefix is `custom` and the
|
||||||
|
* padding is 8, the output will be `custom `.
|
||||||
|
*
|
||||||
|
* @default 8
|
||||||
|
*/
|
||||||
|
public readonly padding = 8
|
||||||
|
) {}
|
||||||
|
|
||||||
|
plain: typeof console.log = (...args) => {
|
||||||
|
console.log(...this.getArgs(args));
|
||||||
|
};
|
||||||
|
|
||||||
info: typeof console.log = (...args) => {
|
info: typeof console.log = (...args) => {
|
||||||
console.log(ConsoleLog.prefixes.info, ...args);
|
this.plain(ConsoleLog.prefixes.info, ...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
succeed: typeof console.log = (...args) => {
|
succeed: typeof console.log = (...args) => {
|
||||||
|
@ -19,16 +33,25 @@ export default class ConsoleLog {
|
||||||
};
|
};
|
||||||
|
|
||||||
warn: typeof console.log = (...args) => {
|
warn: typeof console.log = (...args) => {
|
||||||
console.warn(ConsoleLog.prefixes.warn, ...args);
|
console.warn(...this.getArgs([ConsoleLog.prefixes.warn, ...args]));
|
||||||
};
|
};
|
||||||
|
|
||||||
error: typeof console.log = (...args) => {
|
error: typeof console.log = (...args) => {
|
||||||
console.error(ConsoleLog.prefixes.error, ...args);
|
console.error(...this.getArgs([ConsoleLog.prefixes.error, ...args]));
|
||||||
};
|
};
|
||||||
|
|
||||||
fatal: (...args: Parameters<typeof console.log>) => never = (...args) => {
|
fatal: (...args: Parameters<typeof console.log>) => never = (...args) => {
|
||||||
console.error(ConsoleLog.prefixes.fatal, ...args);
|
console.error(...this.getArgs([ConsoleLog.prefixes.fatal, ...args]));
|
||||||
// eslint-disable-next-line unicorn/no-process-exit
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
protected getArgs(args: Parameters<typeof console.log>) {
|
||||||
|
if (this.prefix) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return [this.prefix.padEnd(this.padding), ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue