mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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:
parent
dfc1f20d27
commit
fa85b7d0eb
30 changed files with 259 additions and 262 deletions
|
@ -17,7 +17,7 @@ import {
|
|||
createAdminTenantApplicationRole,
|
||||
CloudScope,
|
||||
} from '@logto/schemas';
|
||||
import { Hooks, Tenants } from '@logto/schemas/models';
|
||||
import { Tenants } from '@logto/schemas/models';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
import { raw } from 'slonik-sql-tag-raw';
|
||||
|
@ -94,7 +94,6 @@ export const createTables = async (connection: DatabaseTransactionConnection) =>
|
|||
};
|
||||
|
||||
const allQueries: Array<[string, string]> = [
|
||||
[Hooks.tableName, Hooks.raw],
|
||||
[Tenants.tableName, Tenants.raw],
|
||||
...queries.filter(([file]) => !lifecycleNames.includes(file.slice(1, -4))),
|
||||
];
|
||||
|
|
|
@ -69,7 +69,7 @@ export class TenantsLibrary {
|
|||
const applications = createApplicationsQueries(transaction);
|
||||
const roles = createRolesQuery(transaction);
|
||||
|
||||
/* --- Start --- */
|
||||
/* === Start === */
|
||||
await transaction.start();
|
||||
|
||||
// Init tenant
|
||||
|
@ -116,7 +116,7 @@ export class TenantsLibrary {
|
|||
);
|
||||
|
||||
await transaction.end();
|
||||
/* --- End --- */
|
||||
/* === End === */
|
||||
|
||||
return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator };
|
||||
}
|
||||
|
|
|
@ -36,8 +36,6 @@
|
|||
"@logto/schemas": "workspace:*",
|
||||
"@logto/shared": "workspace:*",
|
||||
"@silverhand/essentials": "2.4.0",
|
||||
"@withtyped/postgres": "^0.8.1",
|
||||
"@withtyped/server": "^0.8.1",
|
||||
"aws-sdk": "^2.1329.0",
|
||||
"chalk": "^5.0.0",
|
||||
"clean-deep": "^3.4.0",
|
||||
|
|
|
@ -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;
|
|
@ -1,15 +1,12 @@
|
|||
import { GlobalValues } from '@logto/shared';
|
||||
import type { Optional } 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 { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||
|
||||
import createPool from './create-pool.js';
|
||||
import createQueryClient from './create-query-client.js';
|
||||
import loadOidcValues from './oidc.js';
|
||||
import { throwNotLoadedError } from './throw-errors.js';
|
||||
import { getTenantEndpoint } from './utils.js';
|
||||
|
@ -40,15 +37,9 @@ export class EnvSet {
|
|||
return this.values.dbUrl;
|
||||
}
|
||||
|
||||
static queryClient = createQueryClient(this.dbUrl, this.isTest);
|
||||
|
||||
/** @deprecated Only for backward compatibility; Will be replaced soon. */
|
||||
static pool = createPool(this.dbUrl, this.isTest);
|
||||
static sharedPool = createPool(this.dbUrl, this.isTest);
|
||||
|
||||
#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>>>;
|
||||
|
||||
constructor(public readonly tenantId: string, public readonly databaseUrl: string) {}
|
||||
|
@ -65,18 +56,6 @@ export class EnvSet {
|
|||
return this.#pool;
|
||||
}
|
||||
|
||||
get queryClient() {
|
||||
if (!this.#queryClient) {
|
||||
return throwNotLoadedError();
|
||||
}
|
||||
|
||||
return this.#queryClient;
|
||||
}
|
||||
|
||||
get queryClientSafe() {
|
||||
return this.#queryClient;
|
||||
}
|
||||
|
||||
get oidc() {
|
||||
if (!this.#oidc) {
|
||||
return throwNotLoadedError();
|
||||
|
@ -89,7 +68,6 @@ export class EnvSet {
|
|||
const pool = await createPool(this.databaseUrl, EnvSet.isTest);
|
||||
|
||||
this.#pool = pool;
|
||||
this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest);
|
||||
|
||||
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
|
||||
|
||||
|
@ -99,7 +77,7 @@ export class EnvSet {
|
|||
}
|
||||
|
||||
async end() {
|
||||
await Promise.all([this.#pool?.end(), this.#queryClient?.end()]);
|
||||
await this.#pool?.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,13 +18,14 @@ try {
|
|||
const app = new Koa({
|
||||
proxy: EnvSet.values.trustProxyHeader,
|
||||
});
|
||||
const sharedAdminPool = await EnvSet.sharedPool;
|
||||
await initI18n();
|
||||
await loadConnectorFactories();
|
||||
await Promise.all([
|
||||
checkRowLevelSecurity(EnvSet.queryClient),
|
||||
checkAlterationState(await EnvSet.pool),
|
||||
checkRowLevelSecurity(sharedAdminPool),
|
||||
checkAlterationState(sharedAdminPool),
|
||||
]);
|
||||
await SystemContext.shared.loadStorageProviderConfig(await EnvSet.pool);
|
||||
await SystemContext.shared.loadStorageProviderConfig(sharedAdminPool);
|
||||
|
||||
// Import last until init completed
|
||||
const { default: initApp } = await import('./app/init.js');
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
import { InteractionEvent, LogResult } from '@logto/schemas';
|
||||
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
|
||||
import type { Hook } from '@logto/schemas';
|
||||
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import type { InferModelType } from '@withtyped/server';
|
||||
import { got } from 'got';
|
||||
|
||||
import type { ModelRouters } from '#src/model-routers/index.js';
|
||||
|
||||
import type { Interaction } from './hook.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -18,19 +15,15 @@ await mockEsmWithActual('@logto/core-kit', () => ({
|
|||
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 queryClient = new MockQueryClient();
|
||||
const queryFunction = jest.fn();
|
||||
|
||||
const url = 'https://logto.gg';
|
||||
const hook: InferModelType<ModelRouters['hook']['model']> = {
|
||||
const hook: Hook = {
|
||||
tenantId: 'bar',
|
||||
id: 'foo',
|
||||
event: HookEvent.PostSignIn,
|
||||
config: { headers: { bar: 'baz' }, url, retries: 3 },
|
||||
createdAt: new Date(),
|
||||
createdAt: Date.now() / 1000,
|
||||
};
|
||||
|
||||
const post = jest
|
||||
|
@ -39,13 +32,9 @@ const post = jest
|
|||
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
|
||||
|
||||
const insertLog = jest.fn();
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient);
|
||||
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
|
||||
const findAllHooks = jest.fn().mockResolvedValue([hook]);
|
||||
|
||||
const { createHookLibrary } = await import('./hook.js');
|
||||
const modelRouters = createModelRouters(new MockQueryClient());
|
||||
const { triggerInteractionHooksIfNeeded } = createHookLibrary(
|
||||
new MockQueries({
|
||||
// @ts-expect-error
|
||||
|
@ -55,14 +44,10 @@ const { triggerInteractionHooksIfNeeded } = createHookLibrary(
|
|||
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
|
||||
},
|
||||
logs: { insertLog },
|
||||
}),
|
||||
modelRouters
|
||||
hooks: { findAllHooks },
|
||||
})
|
||||
);
|
||||
|
||||
const readAll = jest
|
||||
.spyOn(modelRouters.hook.client, 'readAll')
|
||||
.mockResolvedValue({ rows: [hook], rowCount: 1 });
|
||||
|
||||
describe('triggerInteractionHooksIfNeeded()', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -71,7 +56,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
it('should return if no user ID found', async () => {
|
||||
await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn);
|
||||
|
||||
expect(queryFunction).not.toBeCalled();
|
||||
expect(findAllHooks).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should set correct payload when hook triggered', async () => {
|
||||
|
@ -87,7 +72,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
} as Interaction
|
||||
);
|
||||
|
||||
expect(readAll).toHaveBeenCalled();
|
||||
expect(findAllHooks).toHaveBeenCalled();
|
||||
expect(post).toHaveBeenCalledWith(url, {
|
||||
headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' },
|
||||
json: {
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
import { InteractionEvent, LogResult, userInfoSelectFields } from '@logto/schemas';
|
||||
import { HookEventPayload, HookEvent } from '@logto/schemas/models';
|
||||
import {
|
||||
HookEvent,
|
||||
HookEventPayload,
|
||||
InteractionEvent,
|
||||
LogResult,
|
||||
userInfoSelectFields,
|
||||
} from '@logto/schemas';
|
||||
import { trySafe } from '@logto/shared';
|
||||
import { conditional, pick } from '@silverhand/essentials';
|
||||
import type { Response } from 'got';
|
||||
|
@ -8,7 +13,6 @@ import { got, HTTPError } from 'got';
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
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';
|
||||
|
||||
const parseResponse = ({ statusCode, body }: Response) => ({
|
||||
|
@ -25,12 +29,13 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
|
|||
|
||||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
|
||||
export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => {
|
||||
export const createHookLibrary = (queries: Queries) => {
|
||||
const {
|
||||
applications: { findApplicationById },
|
||||
logs: { insertLog },
|
||||
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
|
||||
users: { findUserById },
|
||||
hooks: { findAllHooks },
|
||||
} = queries;
|
||||
|
||||
const triggerInteractionHooksIfNeeded = async (
|
||||
|
@ -47,7 +52,7 @@ export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => {
|
|||
}
|
||||
|
||||
const hookEvent = eventToHook[event];
|
||||
const { rows } = await hook.client.readAll();
|
||||
const rows = await findAllHooks();
|
||||
|
||||
const [user, application] = await Promise.all([
|
||||
trySafe(findUserById(userId)),
|
||||
|
|
|
@ -33,7 +33,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
return { keys: [], issuer: [] };
|
||||
}
|
||||
|
||||
const pool = await EnvSet.pool;
|
||||
const pool = await EnvSet.sharedPool;
|
||||
const { value } = await pool.one<LogtoConfig>(sql`
|
||||
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
|
||||
where ${fields.tenantId} = ${adminTenantId}
|
||||
|
|
|
@ -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(),
|
||||
});
|
56
packages/core/src/queries/hooks.ts
Normal file
56
packages/core/src/queries/hooks.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -1,29 +1,76 @@
|
|||
import { koaAdapter, RequestError } from '@withtyped/server';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
import { generateStandardId } from '@logto/core-kit';
|
||||
import { Hooks } from '@logto/schemas';
|
||||
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';
|
||||
|
||||
// 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>(
|
||||
...[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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience
|
|||
import { createSocialLibrary } from '#src/libraries/social.js';
|
||||
import { createUserLibrary } from '#src/libraries/user.js';
|
||||
import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js';
|
||||
import type { ModelRouters } from '#src/model-routers/index.js';
|
||||
|
||||
import type Queries from './Queries.js';
|
||||
|
||||
|
@ -18,11 +17,11 @@ export default class Libraries {
|
|||
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
|
||||
phrases = createPhraseLibrary(this.queries);
|
||||
resources = createResourceLibrary(this.queries);
|
||||
hooks = createHookLibrary(this.queries, this.modelRouters);
|
||||
hooks = createHookLibrary(this.queries);
|
||||
socials = createSocialLibrary(this.queries, this.connectors);
|
||||
passcodes = createPasscodeLibrary(this.queries, this.connectors);
|
||||
applications = createApplicationLibrary(this.queries);
|
||||
verificationStatuses = createVerificationStatusLibrary(this.queries);
|
||||
|
||||
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
|
||||
constructor(private readonly queries: Queries) {}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { createApplicationQueries } from '#src/queries/application.js';
|
|||
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
|
||||
import { createConnectorQueries } from '#src/queries/connector.js';
|
||||
import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js';
|
||||
import { createHooksQueries } from '#src/queries/hooks.js';
|
||||
import { createLogQueries } from '#src/queries/log.js';
|
||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
|
||||
|
@ -34,6 +35,7 @@ export default class Queries {
|
|||
usersRoles = createUsersRolesQueries(this.pool);
|
||||
applicationsRoles = createApplicationsRolesQueries(this.pool);
|
||||
verificationStatuses = createVerificationStatusQueries(this.pool);
|
||||
hooks = createHooksQueries(this.pool);
|
||||
|
||||
constructor(public readonly pool: CommonQueryMethods) {}
|
||||
}
|
||||
|
|
|
@ -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 koaSpaProxy from '#src/middleware/koa-spa-proxy.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 initMeApis from '#src/routes-me/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 queries: Queries;
|
||||
public readonly libraries: Libraries;
|
||||
public readonly modelRouters: ModelRouters;
|
||||
|
||||
public readonly app: Koa;
|
||||
|
||||
|
@ -56,9 +53,8 @@ export default class Tenant implements TenantContext {
|
|||
}
|
||||
|
||||
private constructor(public readonly envSet: EnvSet, public readonly id: string) {
|
||||
const modelRouters = createModelRouters(envSet.queryClient);
|
||||
const queries = new Queries(envSet.pool);
|
||||
const libraries = new Libraries(queries, modelRouters);
|
||||
const libraries = new Libraries(queries);
|
||||
const isAdminTenant = id === adminTenantId;
|
||||
const mountedApps = [
|
||||
...Object.values(UserApps),
|
||||
|
@ -66,7 +62,6 @@ export default class Tenant implements TenantContext {
|
|||
];
|
||||
|
||||
this.envSet = envSet;
|
||||
this.modelRouters = modelRouters;
|
||||
this.queries = queries;
|
||||
this.libraries = libraries;
|
||||
|
||||
|
@ -90,7 +85,6 @@ export default class Tenant implements TenantContext {
|
|||
provider,
|
||||
queries,
|
||||
libraries,
|
||||
modelRouters,
|
||||
envSet,
|
||||
};
|
||||
// Mount APIs
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
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 Queries from './Queries.js';
|
||||
|
@ -12,5 +11,4 @@ export default abstract class TenantContext {
|
|||
public abstract readonly provider: Provider;
|
||||
public abstract readonly queries: Queries;
|
||||
public abstract readonly libraries: Libraries;
|
||||
public abstract readonly modelRouters: ModelRouters;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,13 @@ export class TenantPool {
|
|||
}
|
||||
|
||||
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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@ import { Systems } from '@logto/schemas';
|
|||
import { Tenants } from '@logto/schemas/models';
|
||||
import { isKeyInObject } from '@logto/shared';
|
||||
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||
import { identifier, sql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
import { parseDsn, stringifyDsn } from 'slonik';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { parseDsn, sql, stringifyDsn } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
|
||||
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.
|
||||
*
|
||||
* 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) => {
|
||||
const { queryClient, dbUrl } = EnvSet;
|
||||
const { sharedPool, dbUrl } = EnvSet;
|
||||
const {
|
||||
tableName,
|
||||
rawKeys: { id, dbUser, dbUserPassword },
|
||||
} = 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)}
|
||||
from ${identifier(tableName)}
|
||||
where ${identifier(id)} = ${tenantId}
|
||||
|
@ -33,17 +36,18 @@ export const getTenantDatabaseDsn = async (tenantId: string) => {
|
|||
}
|
||||
|
||||
const options = parseDsn(dbUrl);
|
||||
const username = rows[0][dbUser];
|
||||
const password = rows[0][dbUserPassword];
|
||||
const { dbUser: username, dbUserPassword: password } = z
|
||||
.object({ dbUser: z.string(), dbUserPassword: z.string().optional() })
|
||||
.parse(rows[0]);
|
||||
|
||||
return stringifyDsn({
|
||||
...options,
|
||||
username: conditional(typeof username === 'string' && username),
|
||||
username,
|
||||
password: conditional(typeof password === 'string' && password),
|
||||
});
|
||||
};
|
||||
|
||||
export const checkRowLevelSecurity = async (client: QueryClient) => {
|
||||
export const checkRowLevelSecurity = async (client: CommonQueryMethods) => {
|
||||
const { rows } = await client.query(sql`
|
||||
select tablename
|
||||
from pg_catalog.pg_tables
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { createMockPool, createMockQueryResult } from 'slonik';
|
||||
|
||||
import { createModelRouters } from '#src/model-routers/index.js';
|
||||
import Libraries from '#src/tenants/Libraries.js';
|
||||
import Queries from '#src/tenants/Queries.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 type { GrantMock } from './oidc-provider.js';
|
||||
import { createMockProvider } from './oidc-provider.js';
|
||||
import { MockQueryClient } from './query-client.js';
|
||||
|
||||
export class MockQueries extends Queries {
|
||||
constructor(queriesOverride?: Partial2<Queries>) {
|
||||
|
@ -49,7 +47,6 @@ export class MockTenant implements TenantContext {
|
|||
public envSet = mockEnvSet;
|
||||
public queries: Queries;
|
||||
public libraries: Libraries;
|
||||
public modelRouters = createModelRouters(new MockQueryClient());
|
||||
|
||||
constructor(
|
||||
public provider = createMockProvider(),
|
||||
|
@ -57,7 +54,7 @@ export class MockTenant implements TenantContext {
|
|||
librariesOverride?: Partial2<Libraries>
|
||||
) {
|
||||
this.queries = new MockQueries(queriesOverride);
|
||||
this.libraries = new Libraries(this.queries, this.modelRouters);
|
||||
this.libraries = new Libraries(this.queries);
|
||||
this.setPartial('libraries', librariesOverride);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { conditional } from '@silverhand/essentials';
|
|||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { ZodStringDef } from 'zod';
|
||||
import {
|
||||
ZodRecord,
|
||||
ZodArray,
|
||||
ZodBoolean,
|
||||
ZodEffects,
|
||||
|
@ -209,6 +210,13 @@ export const zodTypeToSwagger = (
|
|||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodRecord) {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: zodTypeToSwagger(config.valueSchema),
|
||||
};
|
||||
}
|
||||
|
||||
if (config instanceof ZodArray) {
|
||||
return {
|
||||
type: 'array',
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import type { LogKey } from '@logto/schemas';
|
||||
import { 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 type { Hook, LogKey } from '@logto/schemas';
|
||||
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.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 { waitFor } from '#src/utils.js';
|
||||
|
||||
type Hook = InferModelType<typeof Hooks>;
|
||||
|
||||
const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> => ({
|
||||
event,
|
||||
config: {
|
||||
|
|
|
@ -9,17 +9,13 @@ export {
|
|||
type Storage,
|
||||
} from '@logto/connector-kit';
|
||||
|
||||
/**
|
||||
* Commonly Used
|
||||
*/
|
||||
/* === Commonly Used === */
|
||||
|
||||
export const arbitraryObjectGuard = z.record(z.unknown());
|
||||
|
||||
export type ArbitraryObject = z.infer<typeof arbitraryObjectGuard>;
|
||||
|
||||
/**
|
||||
* OIDC Model Instances
|
||||
*/
|
||||
/* === OIDC Model Instances === */
|
||||
|
||||
export const oidcModelInstancePayloadGuard = z
|
||||
.object({
|
||||
|
@ -79,9 +75,7 @@ export const customClientMetadataGuard = z.object({
|
|||
|
||||
export type CustomClientMetadata = z.infer<typeof customClientMetadataGuard>;
|
||||
|
||||
/**
|
||||
* Users
|
||||
*/
|
||||
/* === Users === */
|
||||
export const roleNamesGuard = z.string().array();
|
||||
|
||||
const identityGuard = z.object({
|
||||
|
@ -93,9 +87,7 @@ export const identitiesGuard = z.record(identityGuard);
|
|||
export type Identity = z.infer<typeof identityGuard>;
|
||||
export type Identities = z.infer<typeof identitiesGuard>;
|
||||
|
||||
/**
|
||||
* SignIn Experiences
|
||||
*/
|
||||
/* === SignIn Experiences === */
|
||||
|
||||
export const colorGuard = z.object({
|
||||
primaryColor: z.string().regex(hexColorRegEx),
|
||||
|
@ -151,9 +143,7 @@ export const connectorTargetsGuard = z.string().array();
|
|||
|
||||
export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
|
||||
|
||||
/**
|
||||
* Settings
|
||||
*/
|
||||
/* === Logto Configs === */
|
||||
|
||||
export enum AppearanceMode {
|
||||
SyncWithSystem = 'system',
|
||||
|
@ -161,9 +151,7 @@ export enum AppearanceMode {
|
|||
DarkMode = 'dark',
|
||||
}
|
||||
|
||||
/**
|
||||
* Phrases
|
||||
*/
|
||||
/* === Phrases === */
|
||||
|
||||
export type Translation = {
|
||||
[key: string]: string | Translation;
|
||||
|
@ -173,9 +161,7 @@ export const translationGuard: z.ZodType<Translation> = z.lazy(() =>
|
|||
z.record(z.string().or(translationGuard))
|
||||
);
|
||||
|
||||
/**
|
||||
* Logs
|
||||
*/
|
||||
/* === Logs === */
|
||||
|
||||
export enum LogResult {
|
||||
Success = 'Success',
|
||||
|
@ -202,3 +188,34 @@ export const logContextPayloadGuard = z
|
|||
* Here we use `string` to make it compatible with the Zod guard.
|
||||
**/
|
||||
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),
|
||||
});
|
||||
|
|
|
@ -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');
|
|
@ -1,2 +1 @@
|
|||
export * from './hooks.js';
|
||||
export * from './tenants.js';
|
||||
|
|
14
packages/schemas/src/types/hook.ts
Normal file
14
packages/schemas/src/types/hook.ts
Normal 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>;
|
|
@ -13,3 +13,4 @@ export * from './application.js';
|
|||
export * from './system.js';
|
||||
export * from './tenant.js';
|
||||
export * from './user-assets.js';
|
||||
export * from './hook.js';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { HookEvent } from '../../models/hooks.js';
|
||||
import type { HookEvent } from '../../foundations/index.js';
|
||||
|
||||
/** The type of a hook event. */
|
||||
export enum Type {
|
||||
|
|
13
packages/schemas/tables/hooks.sql
Normal file
13
packages/schemas/tables/hooks.sql
Normal 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);
|
|
@ -361,8 +361,6 @@ importers:
|
|||
'@types/semver': ^7.3.12
|
||||
'@types/sinon': ^10.0.13
|
||||
'@types/supertest': ^2.0.11
|
||||
'@withtyped/postgres': ^0.8.1
|
||||
'@withtyped/server': ^0.8.1
|
||||
aws-sdk: ^2.1329.0
|
||||
chalk: ^5.0.0
|
||||
clean-deep: ^3.4.0
|
||||
|
@ -425,8 +423,6 @@ importers:
|
|||
'@logto/schemas': link:../schemas
|
||||
'@logto/shared': link:../shared
|
||||
'@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
|
||||
chalk: 5.1.2
|
||||
clean-deep: 3.4.0
|
||||
|
|
Loading…
Reference in a new issue