0
Fork 0
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:
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,
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))),
];

View file

@ -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 };
}

View file

@ -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",

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 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();
}
}

View file

@ -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');

View file

@ -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: {

View file

@ -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)),

View file

@ -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}

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 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();
}
);
}

View file

@ -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) {}
}

View file

@ -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) {}
}

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 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

View file

@ -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;
}

View file

@ -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();
})
);
}
}

View file

@ -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

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 { 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);
}

View file

@ -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',

View file

@ -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: {

View file

@ -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),
});

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';

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 './tenant.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. */
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/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