0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor: remove withtyped in core (#3375)

Keeping withtyped will introduce an additional database pool per tenant,
which is not good for performance and it should be more like all-or-nothing choice.
So remove it for core, but keep it in cloud.
This commit is contained in:
Gao Sun 2023-03-13 12:01:14 +08:00 committed by GitHub
parent dfc1f20d27
commit fa85b7d0eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 259 additions and 262 deletions

View file

@ -17,7 +17,7 @@ import {
createAdminTenantApplicationRole, createAdminTenantApplicationRole,
CloudScope, CloudScope,
} from '@logto/schemas'; } from '@logto/schemas';
import { Hooks, Tenants } from '@logto/schemas/models'; import { Tenants } from '@logto/schemas/models';
import type { DatabaseTransactionConnection } from 'slonik'; import type { DatabaseTransactionConnection } from 'slonik';
import { sql } from 'slonik'; import { sql } from 'slonik';
import { raw } from 'slonik-sql-tag-raw'; import { raw } from 'slonik-sql-tag-raw';
@ -94,7 +94,6 @@ export const createTables = async (connection: DatabaseTransactionConnection) =>
}; };
const allQueries: Array<[string, string]> = [ const allQueries: Array<[string, string]> = [
[Hooks.tableName, Hooks.raw],
[Tenants.tableName, Tenants.raw], [Tenants.tableName, Tenants.raw],
...queries.filter(([file]) => !lifecycleNames.includes(file.slice(1, -4))), ...queries.filter(([file]) => !lifecycleNames.includes(file.slice(1, -4))),
]; ];

View file

@ -69,7 +69,7 @@ export class TenantsLibrary {
const applications = createApplicationsQueries(transaction); const applications = createApplicationsQueries(transaction);
const roles = createRolesQuery(transaction); const roles = createRolesQuery(transaction);
/* --- Start --- */ /* === Start === */
await transaction.start(); await transaction.start();
// Init tenant // Init tenant
@ -116,7 +116,7 @@ export class TenantsLibrary {
); );
await transaction.end(); await transaction.end();
/* --- End --- */ /* === End === */
return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator }; return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator };
} }

View file

@ -36,8 +36,6 @@
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*", "@logto/shared": "workspace:*",
"@silverhand/essentials": "2.4.0", "@silverhand/essentials": "2.4.0",
"@withtyped/postgres": "^0.8.1",
"@withtyped/server": "^0.8.1",
"aws-sdk": "^2.1329.0", "aws-sdk": "^2.1329.0",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",

View file

@ -1,18 +0,0 @@
import { assert } from '@silverhand/essentials';
import { PostgresQueryClient } from '@withtyped/postgres';
import { parseDsn } from 'slonik';
import { MockQueryClient } from '#src/test-utils/query-client.js';
const createQueryClient = (databaseDsn: string, isTest: boolean) => {
// Database connection is disabled in unit test environment
if (isTest) {
return new MockQueryClient();
}
assert(parseDsn(databaseDsn), new Error('Database name is required'));
return new PostgresQueryClient({ connectionString: databaseDsn });
};
export default createQueryClient;

View file

@ -1,15 +1,12 @@
import { GlobalValues } from '@logto/shared'; import { 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 { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik'; import type { DatabasePool } from 'slonik';
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';
import createPool from './create-pool.js'; import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js'; import loadOidcValues from './oidc.js';
import { throwNotLoadedError } from './throw-errors.js'; import { throwNotLoadedError } from './throw-errors.js';
import { getTenantEndpoint } from './utils.js'; import { getTenantEndpoint } from './utils.js';
@ -40,15 +37,9 @@ export class EnvSet {
return this.values.dbUrl; return this.values.dbUrl;
} }
static queryClient = createQueryClient(this.dbUrl, this.isTest); static sharedPool = createPool(this.dbUrl, this.isTest);
/** @deprecated Only for backward compatibility; Will be replaced soon. */
static pool = createPool(this.dbUrl, this.isTest);
#pool: Optional<DatabasePool>; #pool: Optional<DatabasePool>;
// Use another pool for `withtyped` while adopting the new model,
// as we cannot extract the original PgPool from slonik
#queryClient: Optional<QueryClient<PostgreSql>>;
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>; #oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
constructor(public readonly tenantId: string, public readonly databaseUrl: string) {} constructor(public readonly tenantId: string, public readonly databaseUrl: string) {}
@ -65,18 +56,6 @@ export class EnvSet {
return this.#pool; return this.#pool;
} }
get queryClient() {
if (!this.#queryClient) {
return throwNotLoadedError();
}
return this.#queryClient;
}
get queryClientSafe() {
return this.#queryClient;
}
get oidc() { get oidc() {
if (!this.#oidc) { if (!this.#oidc) {
return throwNotLoadedError(); return throwNotLoadedError();
@ -89,7 +68,6 @@ export class EnvSet {
const pool = await createPool(this.databaseUrl, EnvSet.isTest); const pool = await createPool(this.databaseUrl, EnvSet.isTest);
this.#pool = pool; this.#pool = pool;
this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest);
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool)); const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
@ -99,7 +77,7 @@ export class EnvSet {
} }
async end() { async end() {
await Promise.all([this.#pool?.end(), this.#queryClient?.end()]); await this.#pool?.end();
} }
} }

View file

@ -18,13 +18,14 @@ try {
const app = new Koa({ const app = new Koa({
proxy: EnvSet.values.trustProxyHeader, proxy: EnvSet.values.trustProxyHeader,
}); });
const sharedAdminPool = await EnvSet.sharedPool;
await initI18n(); await initI18n();
await loadConnectorFactories(); await loadConnectorFactories();
await Promise.all([ await Promise.all([
checkRowLevelSecurity(EnvSet.queryClient), checkRowLevelSecurity(sharedAdminPool),
checkAlterationState(await EnvSet.pool), checkAlterationState(sharedAdminPool),
]); ]);
await SystemContext.shared.loadStorageProviderConfig(await EnvSet.pool); await SystemContext.shared.loadStorageProviderConfig(sharedAdminPool);
// Import last until init completed // Import last until init completed
const { default: initApp } = await import('./app/init.js'); const { default: initApp } = await import('./app/init.js');

View file

@ -1,11 +1,8 @@
import { InteractionEvent, LogResult } from '@logto/schemas'; import type { Hook } from '@logto/schemas';
import { HookEvent } from '@logto/schemas/lib/models/hooks.js'; import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm'; import { createMockUtils } from '@logto/shared/esm';
import type { InferModelType } from '@withtyped/server';
import { got } from 'got'; import { got } from 'got';
import type { ModelRouters } from '#src/model-routers/index.js';
import type { Interaction } from './hook.js'; import type { Interaction } from './hook.js';
const { jest } = import.meta; const { jest } = import.meta;
@ -18,19 +15,15 @@ await mockEsmWithActual('@logto/core-kit', () => ({
generateStandardId: () => nanoIdMock, generateStandardId: () => nanoIdMock,
})); }));
const { createModelRouters } = await import('#src/model-routers/index.js');
const { MockQueryClient } = await import('#src/test-utils/query-client.js');
const { MockQueries } = await import('#src/test-utils/tenant.js'); const { MockQueries } = await import('#src/test-utils/tenant.js');
const queryClient = new MockQueryClient();
const queryFunction = jest.fn();
const url = 'https://logto.gg'; const url = 'https://logto.gg';
const hook: InferModelType<ModelRouters['hook']['model']> = { const hook: Hook = {
tenantId: 'bar',
id: 'foo', id: 'foo',
event: HookEvent.PostSignIn, event: HookEvent.PostSignIn,
config: { headers: { bar: 'baz' }, url, retries: 3 }, config: { headers: { bar: 'baz' }, url, retries: 3 },
createdAt: new Date(), createdAt: Date.now() / 1000,
}; };
const post = jest const post = jest
@ -39,13 +32,9 @@ const post = jest
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' }))); .mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
const insertLog = jest.fn(); const insertLog = jest.fn();
const findAllHooks = jest.fn().mockResolvedValue([hook]);
// eslint-disable-next-line unicorn/consistent-function-scoping
mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient);
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
const { createHookLibrary } = await import('./hook.js'); const { createHookLibrary } = await import('./hook.js');
const modelRouters = createModelRouters(new MockQueryClient());
const { triggerInteractionHooksIfNeeded } = createHookLibrary( const { triggerInteractionHooksIfNeeded } = createHookLibrary(
new MockQueries({ new MockQueries({
// @ts-expect-error // @ts-expect-error
@ -55,14 +44,10 @@ const { triggerInteractionHooksIfNeeded } = createHookLibrary(
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }), findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
}, },
logs: { insertLog }, logs: { insertLog },
}), hooks: { findAllHooks },
modelRouters })
); );
const readAll = jest
.spyOn(modelRouters.hook.client, 'readAll')
.mockResolvedValue({ rows: [hook], rowCount: 1 });
describe('triggerInteractionHooksIfNeeded()', () => { describe('triggerInteractionHooksIfNeeded()', () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -71,7 +56,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
it('should return if no user ID found', async () => { it('should return if no user ID found', async () => {
await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn); await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn);
expect(queryFunction).not.toBeCalled(); expect(findAllHooks).not.toBeCalled();
}); });
it('should set correct payload when hook triggered', async () => { it('should set correct payload when hook triggered', async () => {
@ -87,7 +72,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
} as Interaction } as Interaction
); );
expect(readAll).toHaveBeenCalled(); expect(findAllHooks).toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(url, { expect(post).toHaveBeenCalledWith(url, {
headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' }, headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' },
json: { json: {

View file

@ -1,6 +1,11 @@
import { generateStandardId } from '@logto/core-kit'; import { generateStandardId } from '@logto/core-kit';
import { InteractionEvent, LogResult, userInfoSelectFields } from '@logto/schemas'; import {
import { HookEventPayload, HookEvent } from '@logto/schemas/models'; HookEvent,
HookEventPayload,
InteractionEvent,
LogResult,
userInfoSelectFields,
} from '@logto/schemas';
import { trySafe } from '@logto/shared'; import { trySafe } from '@logto/shared';
import { conditional, pick } from '@silverhand/essentials'; import { conditional, pick } from '@silverhand/essentials';
import type { Response } from 'got'; import type { Response } from 'got';
@ -8,7 +13,6 @@ import { got, HTTPError } from 'got';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import { LogEntry } from '#src/middleware/koa-audit-log.js'; import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
const parseResponse = ({ statusCode, body }: Response) => ({ const parseResponse = ({ statusCode, body }: Response) => ({
@ -25,12 +29,13 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>; export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => { export const createHookLibrary = (queries: Queries) => {
const { const {
applications: { findApplicationById }, applications: { findApplicationById },
logs: { insertLog }, logs: { insertLog },
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload? // TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
users: { findUserById }, users: { findUserById },
hooks: { findAllHooks },
} = queries; } = queries;
const triggerInteractionHooksIfNeeded = async ( const triggerInteractionHooksIfNeeded = async (
@ -47,7 +52,7 @@ export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => {
} }
const hookEvent = eventToHook[event]; const hookEvent = eventToHook[event];
const { rows } = await hook.client.readAll(); const rows = await findAllHooks();
const [user, application] = await Promise.all([ const [user, application] = await Promise.all([
trySafe(findUserById(userId)), trySafe(findUserById(userId)),

View file

@ -33,7 +33,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
return { keys: [], issuer: [] }; return { keys: [], issuer: [] };
} }
const pool = await EnvSet.pool; const pool = await EnvSet.sharedPool;
const { value } = await pool.one<LogtoConfig>(sql` const { value } = await pool.one<LogtoConfig>(sql`
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
where ${fields.tenantId} = ${adminTenantId} where ${fields.tenantId} = ${adminTenantId}

View file

@ -1,9 +0,0 @@
import { Hooks } from '@logto/schemas/models';
import { createModelRouter } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
export type ModelRouters = ReturnType<typeof createModelRouters>;
export const createModelRouters = (queryClient: QueryClient) => ({
hook: createModelRouter(Hooks, queryClient).withCrud(),
});

View file

@ -0,0 +1,56 @@
import type { CreateHook, Hook } from '@logto/schemas';
import { Hooks } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers, manyRows } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
const { table, fields } = convertToIdentifiers(Hooks);
export const createHooksQueries = (pool: CommonQueryMethods) => {
const findAllHooks = async () =>
manyRows(
pool.query<Hook>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
`)
);
const findHookById = buildFindEntityByIdWithPool(pool)<CreateHook, Hook>(Hooks);
const insertHook = buildInsertIntoWithPool(pool)<CreateHook, Hook>(Hooks, {
returning: true,
});
const updateHook = buildUpdateWhereWithPool(pool)<CreateHook, Hook>(Hooks, true);
const updateHookById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateHook>>,
jsonbMode: 'replace' | 'merge' = 'merge'
) => updateHook({ set, where: { id }, jsonbMode });
const deleteHookById = async (id: string) => {
const { rowCount } = await pool.query(sql`
delete from ${table}
where ${fields.id}=${id}
`);
if (rowCount < 1) {
throw new DeletionError(Hooks.table, id);
}
};
return {
findAllHooks,
findHookById,
insertHook,
updateHookById,
deleteHookById,
};
};

View file

@ -1,29 +1,76 @@
import { koaAdapter, RequestError } from '@withtyped/server'; import { generateStandardId } from '@logto/core-kit';
import type { MiddlewareType } from 'koa'; import { Hooks } from '@logto/schemas';
import koaBody from 'koa-body'; import { z } from 'zod';
import LogtoRequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from './types.js'; import type { AuthedRouter, RouterInitArgs } from './types.js';
// Organize this function if we decide to adopt withtyped eventually
const errorHandler: MiddlewareType = async (_, next) => {
try {
await next();
} catch (error: unknown) {
if (error instanceof RequestError) {
throw new LogtoRequestError(
{ code: 'request.general', status: error.status },
error.original
);
}
throw error;
}
};
export default function hookRoutes<T extends AuthedRouter>( export default function hookRoutes<T extends AuthedRouter>(
...[router, { modelRouters }]: RouterInitArgs<T> ...[router, { queries }]: RouterInitArgs<T>
) { ) {
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouters.hook.routes())); const { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById } = queries.hooks;
router.get('/hooks', async (ctx, next) => {
ctx.body = await findAllHooks();
return next();
});
router.post(
'/hooks',
koaGuard({ body: Hooks.createGuard.omit({ id: true }) }),
async (ctx, next) => {
ctx.body = await insertHook({
id: generateStandardId(),
...ctx.guard.body,
});
return next();
}
);
router.get(
'/hooks/:id',
koaGuard({ params: z.object({ id: z.string().min(1) }) }),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
ctx.body = await findHookById(id);
return next();
}
);
router.patch(
'/hooks/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: Hooks.createGuard.omit({ id: true }).partial(),
}),
async (ctx, next) => {
const {
params: { id },
body,
} = ctx.guard;
ctx.body = await updateHookById(id, body);
return next();
}
);
router.delete(
'/hooks/:id',
koaGuard({ params: z.object({ id: z.string().min(1) }) }),
async (ctx, next) => {
const { id } = ctx.guard.params;
await deleteHookById(id);
ctx.status = 204;
return next();
}
);
} }

View file

@ -8,7 +8,6 @@ import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience
import { createSocialLibrary } from '#src/libraries/social.js'; import { createSocialLibrary } from '#src/libraries/social.js';
import { createUserLibrary } from '#src/libraries/user.js'; import { createUserLibrary } from '#src/libraries/user.js';
import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js'; import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import type Queries from './Queries.js'; import type Queries from './Queries.js';
@ -18,11 +17,11 @@ export default class Libraries {
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors); signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
phrases = createPhraseLibrary(this.queries); phrases = createPhraseLibrary(this.queries);
resources = createResourceLibrary(this.queries); resources = createResourceLibrary(this.queries);
hooks = createHookLibrary(this.queries, this.modelRouters); hooks = createHookLibrary(this.queries);
socials = createSocialLibrary(this.queries, this.connectors); socials = createSocialLibrary(this.queries, this.connectors);
passcodes = createPasscodeLibrary(this.queries, this.connectors); passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries); applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries);
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {} constructor(private readonly queries: Queries) {}
} }

View file

@ -4,6 +4,7 @@ import { createApplicationQueries } from '#src/queries/application.js';
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js'; import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
import { createConnectorQueries } from '#src/queries/connector.js'; import { createConnectorQueries } from '#src/queries/connector.js';
import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js'; import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js';
import { createHooksQueries } from '#src/queries/hooks.js';
import { createLogQueries } from '#src/queries/log.js'; import { createLogQueries } from '#src/queries/log.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js'; import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
@ -34,6 +35,7 @@ export default class Queries {
usersRoles = createUsersRolesQueries(this.pool); usersRoles = createUsersRolesQueries(this.pool);
applicationsRoles = createApplicationsRolesQueries(this.pool); applicationsRoles = createApplicationsRolesQueries(this.pool);
verificationStatuses = createVerificationStatusQueries(this.pool); verificationStatuses = createVerificationStatusQueries(this.pool);
hooks = createHooksQueries(this.pool);
constructor(public readonly pool: CommonQueryMethods) {} constructor(public readonly pool: CommonQueryMethods) {}
} }

View file

@ -16,8 +16,6 @@ import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.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'; import initOidc from '#src/oidc/init.js';
import initMeApis from '#src/routes-me/init.js'; import initMeApis from '#src/routes-me/init.js';
import initApis from '#src/routes/init.js'; import initApis from '#src/routes/init.js';
@ -39,7 +37,6 @@ export default class Tenant implements TenantContext {
public readonly provider: Provider; public readonly provider: Provider;
public readonly queries: Queries; public readonly queries: Queries;
public readonly libraries: Libraries; public readonly libraries: Libraries;
public readonly modelRouters: ModelRouters;
public readonly app: Koa; public readonly app: Koa;
@ -56,9 +53,8 @@ export default class Tenant implements TenantContext {
} }
private constructor(public readonly envSet: EnvSet, public readonly id: string) { private constructor(public readonly envSet: EnvSet, public readonly id: string) {
const modelRouters = createModelRouters(envSet.queryClient);
const queries = new Queries(envSet.pool); const queries = new Queries(envSet.pool);
const libraries = new Libraries(queries, modelRouters); const libraries = new Libraries(queries);
const isAdminTenant = id === adminTenantId; const isAdminTenant = id === adminTenantId;
const mountedApps = [ const mountedApps = [
...Object.values(UserApps), ...Object.values(UserApps),
@ -66,7 +62,6 @@ export default class Tenant implements TenantContext {
]; ];
this.envSet = envSet; this.envSet = envSet;
this.modelRouters = modelRouters;
this.queries = queries; this.queries = queries;
this.libraries = libraries; this.libraries = libraries;
@ -90,7 +85,6 @@ export default class Tenant implements TenantContext {
provider, provider,
queries, queries,
libraries, libraries,
modelRouters,
envSet, envSet,
}; };
// Mount APIs // Mount APIs

View file

@ -1,7 +1,6 @@
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import type { EnvSet } from '#src/env-set/index.js'; import type { EnvSet } from '#src/env-set/index.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import type Libraries from './Libraries.js'; import type Libraries from './Libraries.js';
import type Queries from './Queries.js'; import type Queries from './Queries.js';
@ -12,5 +11,4 @@ export default abstract class TenantContext {
public abstract readonly provider: Provider; public abstract readonly provider: Provider;
public abstract readonly queries: Queries; public abstract readonly queries: Queries;
public abstract readonly libraries: Libraries; public abstract readonly libraries: Libraries;
public abstract readonly modelRouters: ModelRouters;
} }

View file

@ -25,7 +25,13 @@ export class TenantPool {
} }
async endAll(): Promise<void> { async endAll(): Promise<void> {
await Promise.all(this.cache.dump().map(async ([, tenant]) => tenant.value.envSet.end())); await Promise.all(
this.cache.dump().map(([, tenant]) => {
const { poolSafe } = tenant.value.envSet;
return poolSafe?.end();
})
);
} }
} }

View file

@ -2,9 +2,9 @@ import { Systems } from '@logto/schemas';
import { Tenants } from '@logto/schemas/models'; import { Tenants } from '@logto/schemas/models';
import { isKeyInObject } from '@logto/shared'; import { isKeyInObject } from '@logto/shared';
import { conditional, conditionalString } from '@silverhand/essentials'; import { conditional, conditionalString } from '@silverhand/essentials';
import { identifier, sql } from '@withtyped/postgres'; import type { CommonQueryMethods } from 'slonik';
import type { QueryClient } from '@withtyped/server'; import { parseDsn, sql, stringifyDsn } from 'slonik';
import { parseDsn, stringifyDsn } from 'slonik'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
@ -13,16 +13,19 @@ export class TenantNotFoundError extends Error {}
/** /**
* This function is to fetch the tenant password for the corresponding Postgres user. * This function is to fetch the tenant password for the corresponding Postgres user.
* *
* In multi-tenancy mode, Logto should ALWAYS use a restricted user with RLS enforced to ensure data isolation between tenants. * ** **CAUTION** ** In multi-tenancy mode, Logto should ALWAYS use a restricted user with RLS enforced to ensure data isolation between tenants.
*/ */
export const getTenantDatabaseDsn = async (tenantId: string) => { export const getTenantDatabaseDsn = async (tenantId: string) => {
const { queryClient, dbUrl } = EnvSet; const { sharedPool, dbUrl } = EnvSet;
const { const {
tableName, tableName,
rawKeys: { id, dbUser, dbUserPassword }, rawKeys: { id, dbUser, dbUserPassword },
} = Tenants; } = Tenants;
const { rows } = await queryClient.query(sql` const identifier = (id: string) => sql.identifier([id]);
const pool = await sharedPool;
const { rows } = await pool.query(sql`
select ${identifier(dbUser)}, ${identifier(dbUserPassword)} select ${identifier(dbUser)}, ${identifier(dbUserPassword)}
from ${identifier(tableName)} from ${identifier(tableName)}
where ${identifier(id)} = ${tenantId} where ${identifier(id)} = ${tenantId}
@ -33,17 +36,18 @@ export const getTenantDatabaseDsn = async (tenantId: string) => {
} }
const options = parseDsn(dbUrl); const options = parseDsn(dbUrl);
const username = rows[0][dbUser]; const { dbUser: username, dbUserPassword: password } = z
const password = rows[0][dbUserPassword]; .object({ dbUser: z.string(), dbUserPassword: z.string().optional() })
.parse(rows[0]);
return stringifyDsn({ return stringifyDsn({
...options, ...options,
username: conditional(typeof username === 'string' && username), username,
password: conditional(typeof password === 'string' && password), password: conditional(typeof password === 'string' && password),
}); });
}; };
export const checkRowLevelSecurity = async (client: QueryClient) => { export const checkRowLevelSecurity = async (client: CommonQueryMethods) => {
const { rows } = await client.query(sql` const { rows } = await client.query(sql`
select tablename select tablename
from pg_catalog.pg_tables from pg_catalog.pg_tables

View file

@ -1,24 +0,0 @@
import type { PostgreSql } from '@withtyped/postgres';
import type { Transaction } from '@withtyped/server';
import { QueryClient } from '@withtyped/server';
// Consider move to withtyped if everything goes well
export class MockQueryClient extends QueryClient<PostgreSql> {
async transaction(): Promise<Transaction<PostgreSql>> {
throw new Error('Method not implemented.');
}
async connect() {
console.debug('MockQueryClient connect');
}
async end() {
console.debug('MockQueryClient end');
}
async query() {
console.debug('MockQueryClient query');
return { rows: [], rowCount: 0 };
}
}

View file

@ -1,6 +1,5 @@
import { createMockPool, createMockQueryResult } from 'slonik'; import { createMockPool, createMockQueryResult } from 'slonik';
import { createModelRouters } from '#src/model-routers/index.js';
import Libraries from '#src/tenants/Libraries.js'; import Libraries from '#src/tenants/Libraries.js';
import Queries from '#src/tenants/Queries.js'; import Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
@ -8,7 +7,6 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import { mockEnvSet } from './env-set.js'; import { mockEnvSet } from './env-set.js';
import type { GrantMock } from './oidc-provider.js'; import type { GrantMock } from './oidc-provider.js';
import { createMockProvider } from './oidc-provider.js'; import { createMockProvider } from './oidc-provider.js';
import { MockQueryClient } from './query-client.js';
export class MockQueries extends Queries { export class MockQueries extends Queries {
constructor(queriesOverride?: Partial2<Queries>) { constructor(queriesOverride?: Partial2<Queries>) {
@ -49,7 +47,6 @@ export class MockTenant implements TenantContext {
public envSet = mockEnvSet; public envSet = mockEnvSet;
public queries: Queries; public queries: Queries;
public libraries: Libraries; public libraries: Libraries;
public modelRouters = createModelRouters(new MockQueryClient());
constructor( constructor(
public provider = createMockProvider(), public provider = createMockProvider(),
@ -57,7 +54,7 @@ export class MockTenant implements TenantContext {
librariesOverride?: Partial2<Libraries> librariesOverride?: Partial2<Libraries>
) { ) {
this.queries = new MockQueries(queriesOverride); this.queries = new MockQueries(queriesOverride);
this.libraries = new Libraries(this.queries, this.modelRouters); this.libraries = new Libraries(this.queries);
this.setPartial('libraries', librariesOverride); this.setPartial('libraries', librariesOverride);
} }

View file

@ -5,6 +5,7 @@ import { conditional } from '@silverhand/essentials';
import type { OpenAPIV3 } from 'openapi-types'; import type { OpenAPIV3 } from 'openapi-types';
import type { ZodStringDef } from 'zod'; import type { ZodStringDef } from 'zod';
import { import {
ZodRecord,
ZodArray, ZodArray,
ZodBoolean, ZodBoolean,
ZodEffects, ZodEffects,
@ -209,6 +210,13 @@ export const zodTypeToSwagger = (
}; };
} }
if (config instanceof ZodRecord) {
return {
type: 'object',
additionalProperties: zodTypeToSwagger(config.valueSchema),
};
}
if (config instanceof ZodArray) { if (config instanceof ZodArray) {
return { return {
type: 'array', type: 'array',

View file

@ -1,8 +1,5 @@
import type { LogKey } from '@logto/schemas'; import type { Hook, LogKey } from '@logto/schemas';
import { SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas'; import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
import type { Hooks } from '@logto/schemas/models';
import { HookEvent } from '@logto/schemas/models';
import type { InferModelType } from '@withtyped/server';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js'; import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
import { initClient, processSession } from '#src/helpers/client.js'; import { initClient, processSession } from '#src/helpers/client.js';
@ -11,8 +8,6 @@ import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
import { waitFor } from '#src/utils.js'; import { waitFor } from '#src/utils.js';
type Hook = InferModelType<typeof Hooks>;
const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> => ({ const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> => ({
event, event,
config: { config: {

View file

@ -9,17 +9,13 @@ export {
type Storage, type Storage,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
/** /* === Commonly Used === */
* Commonly Used
*/
export const arbitraryObjectGuard = z.record(z.unknown()); export const arbitraryObjectGuard = z.record(z.unknown());
export type ArbitraryObject = z.infer<typeof arbitraryObjectGuard>; export type ArbitraryObject = z.infer<typeof arbitraryObjectGuard>;
/** /* === OIDC Model Instances === */
* OIDC Model Instances
*/
export const oidcModelInstancePayloadGuard = z export const oidcModelInstancePayloadGuard = z
.object({ .object({
@ -79,9 +75,7 @@ export const customClientMetadataGuard = z.object({
export type CustomClientMetadata = z.infer<typeof customClientMetadataGuard>; export type CustomClientMetadata = z.infer<typeof customClientMetadataGuard>;
/** /* === Users === */
* Users
*/
export const roleNamesGuard = z.string().array(); export const roleNamesGuard = z.string().array();
const identityGuard = z.object({ const identityGuard = z.object({
@ -93,9 +87,7 @@ export const identitiesGuard = z.record(identityGuard);
export type Identity = z.infer<typeof identityGuard>; export type Identity = z.infer<typeof identityGuard>;
export type Identities = z.infer<typeof identitiesGuard>; export type Identities = z.infer<typeof identitiesGuard>;
/** /* === SignIn Experiences === */
* SignIn Experiences
*/
export const colorGuard = z.object({ export const colorGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx), primaryColor: z.string().regex(hexColorRegEx),
@ -151,9 +143,7 @@ export const connectorTargetsGuard = z.string().array();
export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>; export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
/** /* === Logto Configs === */
* Settings
*/
export enum AppearanceMode { export enum AppearanceMode {
SyncWithSystem = 'system', SyncWithSystem = 'system',
@ -161,9 +151,7 @@ export enum AppearanceMode {
DarkMode = 'dark', DarkMode = 'dark',
} }
/** /* === Phrases === */
* Phrases
*/
export type Translation = { export type Translation = {
[key: string]: string | Translation; [key: string]: string | Translation;
@ -173,9 +161,7 @@ export const translationGuard: z.ZodType<Translation> = z.lazy(() =>
z.record(z.string().or(translationGuard)) z.record(z.string().or(translationGuard))
); );
/** /* === Logs === */
* Logs
*/
export enum LogResult { export enum LogResult {
Success = 'Success', Success = 'Success',
@ -202,3 +188,34 @@ export const logContextPayloadGuard = z
* Here we use `string` to make it compatible with the Zod guard. * Here we use `string` to make it compatible with the Zod guard.
**/ **/
export type LogContextPayload = z.infer<typeof logContextPayloadGuard>; export type LogContextPayload = z.infer<typeof logContextPayloadGuard>;
/* === Hooks === */
export enum HookEvent {
PostRegister = 'PostRegister',
PostSignIn = 'PostSignIn',
PostResetPassword = 'PostResetPassword',
}
export const hookEventGuard: z.ZodType<HookEvent> = z.nativeEnum(HookEvent);
export type HookConfig = {
/** We don't need `type` since v1 only has web hook */
// type: 'web';
/** Method fixed to `POST` */
url: string;
/** Additional headers that attach to the request */
headers?: Record<string, string>;
/**
* Retry times when hook response status >= 500.
*
* Must be less than or equal to `3`. Use `0` to disable retry.
**/
retries: number;
};
export const hookConfigGuard: z.ZodType<HookConfig> = z.object({
url: z.string(),
headers: z.record(z.string()).optional(),
retries: z.number().gte(0).lte(3),
});

View file

@ -1,64 +0,0 @@
import { generateStandardId } from '@logto/core-kit';
import { createModel } from '@withtyped/server';
import { z } from 'zod';
import type { Application, User } from '../db-entries/index.js';
import type { userInfoSelectFields } from '../types/index.js';
export enum HookEvent {
PostRegister = 'PostRegister',
PostSignIn = 'PostSignIn',
PostResetPassword = 'PostResetPassword',
}
export type HookEventPayload = {
hookId: string;
event: HookEvent;
createdAt: string;
sessionId?: string;
userAgent?: string;
userId?: string;
user?: Pick<User, (typeof userInfoSelectFields)[number]>;
application?: Pick<Application, 'id' | 'type' | 'name' | 'description'>;
} & Record<string, unknown>;
export type HookConfig = {
/** We don't need `type` since v1 only has web hook */
// type: 'web';
/** Method fixed to `POST` */
url: string;
/** Additional headers that attach to the request */
headers?: Record<string, string>;
/**
* Retry times when hook response status >= 500.
*
* Must be less than or equal to `3`. Use `0` to disable retry.
**/
retries: number;
};
export const hookConfigGuard: z.ZodType<HookConfig> = z.object({
url: z.string(),
headers: z.record(z.string()).optional(),
retries: z.number().gte(0).lte(3),
});
export const Hooks = createModel(/* sql */ `
create table hooks (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
event varchar(128) not null,
config jsonb /* @use HookConfig */ not null,
created_at timestamptz not null default(now()),
primary key (id)
);
create index hooks__id on hooks (tenant_id, id);
create index hooks__event on hooks (tenant_id, event);
`)
.extend('id', { default: () => generateStandardId(), readonly: true })
.extend('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working.
.extend('config', hookConfigGuard)
.exclude('tenantId');

View file

@ -1,2 +1 @@
export * from './hooks.js';
export * from './tenants.js'; export * from './tenants.js';

View file

@ -0,0 +1,14 @@
import type { Application, User } from '../db-entries/index.js';
import type { HookEvent } from '../foundations/index.js';
import type { userInfoSelectFields } from './user.js';
export type HookEventPayload = {
hookId: string;
event: HookEvent;
createdAt: string;
sessionId?: string;
userAgent?: string;
userId?: string;
user?: Pick<User, (typeof userInfoSelectFields)[number]>;
application?: Pick<Application, 'id' | 'type' | 'name' | 'description'>;
} & Record<string, unknown>;

View file

@ -13,3 +13,4 @@ export * from './application.js';
export * from './system.js'; export * from './system.js';
export * from './tenant.js'; export * from './tenant.js';
export * from './user-assets.js'; export * from './user-assets.js';
export * from './hook.js';

View file

@ -1,4 +1,4 @@
import type { HookEvent } from '../../models/hooks.js'; import type { HookEvent } from '../../foundations/index.js';
/** The type of a hook event. */ /** The type of a hook event. */
export enum Type { export enum Type {

View file

@ -0,0 +1,13 @@
create table hooks (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
event varchar(128) /* @use HookEvent */ not null,
config jsonb /* @use HookConfig */ not null,
created_at timestamptz not null default(now()),
primary key (id)
);
create index hooks__id on hooks (tenant_id, id);
create index hooks__event on hooks (tenant_id, event);

View file

@ -361,8 +361,6 @@ importers:
'@types/semver': ^7.3.12 '@types/semver': ^7.3.12
'@types/sinon': ^10.0.13 '@types/sinon': ^10.0.13
'@types/supertest': ^2.0.11 '@types/supertest': ^2.0.11
'@withtyped/postgres': ^0.8.1
'@withtyped/server': ^0.8.1
aws-sdk: ^2.1329.0 aws-sdk: ^2.1329.0
chalk: ^5.0.0 chalk: ^5.0.0
clean-deep: ^3.4.0 clean-deep: ^3.4.0
@ -425,8 +423,6 @@ importers:
'@logto/schemas': link:../schemas '@logto/schemas': link:../schemas
'@logto/shared': link:../shared '@logto/shared': link:../shared
'@silverhand/essentials': 2.4.0 '@silverhand/essentials': 2.4.0
'@withtyped/postgres': 0.8.1_@withtyped+server@0.8.1
'@withtyped/server': 0.8.1
aws-sdk: 2.1329.0 aws-sdk: 2.1329.0
chalk: 5.1.2 chalk: 5.1.2
clean-deep: 3.4.0 clean-deep: 3.4.0