From 95a44929a514f03311db36613e7db03a7193678c Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 18 Jan 2023 20:38:05 +0800 Subject: [PATCH] feat: init multi-tenancy environment (#2929) --- packages/core/package.json | 4 +- packages/core/src/app/init.ts | 27 ++++++++-- packages/core/src/env-set/index.ts | 43 ++++++++++++++- packages/core/src/index.ts | 9 ++-- packages/core/src/middleware/koa-auth.test.ts | 35 ++++++------ packages/core/src/oidc/adapter.test.ts | 8 +-- packages/core/src/oidc/init.test.ts | 4 +- packages/core/src/oidc/utils.test.ts | 10 ++-- packages/core/src/tenants/Tenant.test.ts | 4 +- packages/core/src/tenants/Tenant.ts | 17 +++++- packages/core/src/tenants/index.ts | 1 + packages/core/src/tenants/utils.ts | 54 +++++++++++++++++++ packages/core/src/test-utils/env-set.ts | 6 --- packages/core/src/test-utils/tenant.ts | 4 +- packages/core/src/utils/connectors/loader.ts | 8 +-- packages/integration-tests/package.json | 2 +- packages/schemas/package.json | 2 +- packages/schemas/src/models/index.ts | 1 + packages/schemas/src/models/tenants.ts | 9 ++++ packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/object.ts | 5 ++ pnpm-lock.yaml | 39 ++++++-------- 22 files changed, 210 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/tenants/utils.ts delete mode 100644 packages/core/src/test-utils/env-set.ts create mode 100644 packages/schemas/src/models/tenants.ts create mode 100644 packages/shared/src/utils/object.ts diff --git a/packages/core/package.json b/packages/core/package.json index a26d10ca3..63c650603 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,8 +34,8 @@ "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "2.1.0", - "@withtyped/postgres": "^0.3.1", - "@withtyped/server": "^0.3.1", + "@withtyped/postgres": "^0.4.0", + "@withtyped/server": "^0.4.0", "chalk": "^5.0.0", "clean-deep": "^3.4.0", "date-fns": "^2.29.3", diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 9df4c8fa4..bca2478a3 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -6,20 +6,39 @@ import chalk from 'chalk'; import type Koa from 'koa'; import { EnvSet } from '#src/env-set/index.js'; -import { tenantPool, defaultTenant } from '#src/tenants/index.js'; +import { defaultTenant, tenantPool } from '#src/tenants/index.js'; const logListening = () => { const { localhostUrl, endpoint } = EnvSet.values; - for (const url of deduplicate([localhostUrl, endpoint])) { + for (const url of deduplicate([localhostUrl, endpoint.toString()])) { console.log(chalk.bold(chalk.green(`App is running at ${url}`))); } }; +const getTenantId = () => { + if (!EnvSet.values.isMultiTenancy) { + return defaultTenant; + } + + if (EnvSet.values.multiTenancyMode === 'domain') { + throw new Error('Not implemented'); + } + + return !EnvSet.values.isProduction && EnvSet.values.developmentTenantId; +}; + export default async function initApp(app: Koa): Promise { app.use(async (ctx, next) => { - // TODO: add multi-tenancy logic - const tenant = await tenantPool.get(defaultTenant); + const tenantId = getTenantId(); + + if (!tenantId) { + ctx.status = 404; + + return next(); + } + + const tenant = await tenantPool.get(tenantId); return tenant.run(ctx, next); }); diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index bc5d5251d..7105627a0 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,3 +1,5 @@ +import net from 'net'; + import { tryThat } from '@logto/shared'; import type { Optional } from '@silverhand/essentials'; import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials'; @@ -23,29 +25,64 @@ export enum MountedApps { Welcome = 'welcome', } +type MultiTenancyMode = 'domain' | 'env'; + +const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY'; +const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID'; + const loadEnvValues = () => { const isProduction = getEnv('NODE_ENV') === 'production'; const isTest = getEnv('NODE_ENV') === 'test'; const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST')); const isHttpsEnabled = Boolean(process.env.HTTPS_CERT_PATH && process.env.HTTPS_KEY_PATH); + const isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey)); const port = Number(getEnv('PORT', '3001')); const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`; const endpoint = getEnv('ENDPOINT', localhostUrl); const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage); + const { hostname } = new URL(endpoint); + const multiTenancyMode: MultiTenancyMode = + isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost' ? 'domain' : 'env'; + const developmentTenantId = getEnv(developmentTenantIdKey); + + if (!isMultiTenancy && developmentTenantId) { + throw new Error( + `Multi-tenancy is disabled but development tenant env \`${developmentTenantIdKey}\` found. Please enable multi-tenancy by setting \`${enableMultiTenancyKey}\` to true.` + ); + } + + if (isMultiTenancy && multiTenancyMode === 'env') { + if (isProduction) { + throw new Error( + `Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` + + 'An endpoint with a valid domain is required for multi-tenancy mode.' + ); + } + + console.warn( + '[warn]', + `Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` + + `Logto is using \`${developmentTenantIdKey}\` env (current value: ${developmentTenantId}) for tenant recognition which is not supported in production.` + ); + } + return Object.freeze({ isTest, isIntegrationTest, isProduction, isHttpsEnabled, + isMultiTenancy, httpsCert: process.env.HTTPS_CERT_PATH, httpsKey: process.env.HTTPS_KEY_PATH, port, localhostUrl, endpoint, + multiTenancyMode, dbUrl: databaseUrl, userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'), developmentUserId: getEnv('DEVELOPMENT_USER_ID'), + developmentTenantId, trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')), adminConsoleUrl: appendPath(endpoint, '/console'), }); @@ -53,6 +90,8 @@ const loadEnvValues = () => { export class EnvSet { static values: ReturnType = loadEnvValues(); + static default = new EnvSet(EnvSet.values.dbUrl); + static get isTest() { return this.values.isTest; } @@ -63,7 +102,7 @@ export class EnvSet { #queryClient: Optional>; #oidc: Optional>>; - constructor(public readonly databaseUrl = EnvSet.values.dbUrl) {} + constructor(public readonly databaseUrl: string) {} get pool() { if (!this.#pool) { @@ -111,3 +150,5 @@ export class EnvSet { ); } } + +await EnvSet.default.load(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd7b9b700..799184004 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,15 +3,14 @@ import dotenv from 'dotenv'; import { findUp } from 'find-up'; import Koa from 'koa'; -import initI18n from './i18n/init.js'; - dotenv.config({ path: await findUp('.env', {}) }); // Import after env has configured const { loadConnectorFactories } = await import('./utils/connectors/factories.js'); const { EnvSet } = await import('./env-set/index.js'); -const { tenantPool } = await import('./tenants/index.js'); +const { default: initI18n } = await import('./i18n/init.js'); +const { tenantPool, checkRowLevelSecurity } = await import('./tenants/index.js'); try { const app = new Koa({ @@ -20,6 +19,10 @@ try { await initI18n(); await loadConnectorFactories(); + if (EnvSet.values.isMultiTenancy) { + await checkRowLevelSecurity(EnvSet.default.queryClient); + } + // Import last until init completed const { default: initApp } = await import('./app/init.js'); await initApp(app); diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index b227b18df..0e431c197 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -1,4 +1,4 @@ -import { managementResourceScope } from '@logto/schemas'; +import { managementResourceScope, UserRole } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -6,7 +6,6 @@ import Sinon from 'sinon'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; -import { envSetForTest } from '#src/test-utils/env-set.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import type { WithAuthContext } from './koa-auth.js'; @@ -64,7 +63,7 @@ describe('koaAuth middleware', () => { developmentUserId: 'foo', }); - await koaAuth(envSetForTest)(ctx, next); + await koaAuth(EnvSet.default)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); stub.restore(); @@ -79,7 +78,7 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth(envSetForTest)(mockCtx, next); + await koaAuth(EnvSet.default)(mockCtx, next); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); }); @@ -91,7 +90,7 @@ describe('koaAuth middleware', () => { isIntegrationTest: true, }); - await koaAuth(envSetForTest)(ctx, next); + await koaAuth(EnvSet.default)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); stub.restore(); @@ -112,7 +111,7 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth(envSetForTest)(mockCtx, next); + await koaAuth(EnvSet.default)(mockCtx, next); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); stub.restore(); @@ -125,12 +124,12 @@ describe('koaAuth middleware', () => { authorization: 'Bearer access_token', }, }; - await koaAuth(envSetForTest)(ctx, next); + await koaAuth(EnvSet.default)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' }); }); it('expect to throw if authorization header is missing', async () => { - await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(authHeaderMissingError); + await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(authHeaderMissingError); }); it('expect to throw if authorization header token type not recognized ', async () => { @@ -141,7 +140,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(tokenNotSupportedError); + await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(tokenNotSupportedError); }); it('expect to throw if jwt sub is missing', async () => { @@ -154,7 +153,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(jwtSubMissingError); + await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError(jwtSubMissingError); }); it('expect to have `client` type per jwt verify result', async () => { @@ -167,7 +166,7 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth(envSetForTest)(ctx, next); + await koaAuth(EnvSet.default)(ctx, next); expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); }); @@ -181,9 +180,9 @@ describe('koaAuth middleware', () => { }, }; - await expect( - koaAuth(envSetForTest, managementResourceScope.name)(ctx, next) - ).rejects.toMatchError(forbiddenError); + await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError( + forbiddenError + ); }); it('expect to throw if jwt scope does not include management resource scope', async () => { @@ -198,9 +197,9 @@ describe('koaAuth middleware', () => { }, }; - await expect( - koaAuth(envSetForTest, managementResourceScope.name)(ctx, next) - ).rejects.toMatchError(forbiddenError); + await expect(koaAuth(EnvSet.default, UserRole.Admin)(ctx, next)).rejects.toMatchError( + forbiddenError + ); }); it('expect to throw unauthorized error if unknown error occurs', async () => { @@ -214,7 +213,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError( + await expect(koaAuth(EnvSet.default)(ctx, next)).rejects.toMatchError( new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error')) ); }); diff --git a/packages/core/src/oidc/adapter.test.ts b/packages/core/src/oidc/adapter.test.ts index 79b2a29a1..ef15d8342 100644 --- a/packages/core/src/oidc/adapter.test.ts +++ b/packages/core/src/oidc/adapter.test.ts @@ -3,7 +3,7 @@ import { createMockUtils } from '@logto/shared/esm'; import snakecaseKeys from 'snakecase-keys'; import { mockApplication } from '#src/__mocks__/index.js'; -import { envSetForTest } from '#src/test-utils/env-set.js'; +import { EnvSet } from '#src/env-set/index.js'; import { MockQueries } from '#src/test-utils/tenant.js'; import { getConstantClientMetadata } from './utils.js'; @@ -48,7 +48,7 @@ const now = Date.now(); describe('postgres Adapter', () => { it('Client Modal', async () => { const rejectError = new Error('Not implemented'); - const adapter = postgresAdapter(envSetForTest, queries, 'Client'); + const adapter = postgresAdapter(EnvSet.default, queries, 'Client'); await expect(adapter.upsert('client', {}, 0)).rejects.toMatchError(rejectError); await expect(adapter.findByUserCode('foo')).rejects.toMatchError(rejectError); @@ -72,7 +72,7 @@ describe('postgres Adapter', () => { client_id, client_name, client_secret, - ...getConstantClientMetadata(envSetForTest, type), + ...getConstantClientMetadata(EnvSet.default, type), ...snakecaseKeys(oidcClientMetadata), ...customClientMetadata, }); @@ -85,7 +85,7 @@ describe('postgres Adapter', () => { const id = 'fooId'; const grantId = 'grantId'; const expireAt = 60; - const adapter = postgresAdapter(envSetForTest, queries, modelName); + const adapter = postgresAdapter(EnvSet.default, queries, modelName); await adapter.upsert(id, { uid, userCode }, expireAt); expect(upsertInstance).toBeCalledWith({ diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index ac6f8f67e..f16d92764 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -1,4 +1,4 @@ -import { envSetForTest } from '#src/test-utils/env-set.js'; +import { EnvSet } from '#src/env-set/index.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import initOidc from './init.js'; @@ -7,6 +7,6 @@ describe('oidc provider init', () => { it('init should not throw', async () => { const { queries, libraries } = new MockTenant(); - expect(() => initOidc(envSetForTest, queries, libraries)).not.toThrow(); + expect(() => initOidc(EnvSet.default, queries, libraries)).not.toThrow(); }); }); diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 34fe68a8a..fed59cc9a 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -1,6 +1,6 @@ import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas'; -import { envSetForTest } from '#src/test-utils/env-set.js'; +import { EnvSet } from '#src/env-set/index.js'; import { isOriginAllowed, @@ -10,22 +10,22 @@ import { } from './utils.js'; describe('getConstantClientMetadata()', () => { - expect(getConstantClientMetadata(envSetForTest, ApplicationType.SPA)).toEqual({ + expect(getConstantClientMetadata(EnvSet.default, ApplicationType.SPA)).toEqual({ application_type: 'web', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'none', }); - expect(getConstantClientMetadata(envSetForTest, ApplicationType.Native)).toEqual({ + expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Native)).toEqual({ application_type: 'native', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'none', }); - expect(getConstantClientMetadata(envSetForTest, ApplicationType.Traditional)).toEqual({ + expect(getConstantClientMetadata(EnvSet.default, ApplicationType.Traditional)).toEqual({ application_type: 'web', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'client_secret_basic', }); - expect(getConstantClientMetadata(envSetForTest, ApplicationType.MachineToMachine)).toEqual({ + expect(getConstantClientMetadata(EnvSet.default, ApplicationType.MachineToMachine)).toEqual({ application_type: 'web', grant_types: [GrantType.ClientCredentials], token_endpoint_auth_method: 'client_secret_basic', diff --git a/packages/core/src/tenants/Tenant.test.ts b/packages/core/src/tenants/Tenant.test.ts index 26470533e..870a4344b 100644 --- a/packages/core/src/tenants/Tenant.test.ts +++ b/packages/core/src/tenants/Tenant.test.ts @@ -3,6 +3,8 @@ import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { emptyMiddleware } from '#src/utils/test-utils.js'; +import { defaultTenant } from './consts.js'; + const { jest } = import.meta; const { mockEsm, mockEsmDefault } = createMockUtils(jest); @@ -30,7 +32,7 @@ const Tenant = await pickDefault(import('./Tenant.js')); describe('Tenant', () => { it('should call middleware factories', async () => { - await Tenant.create('foo'); + await Tenant.create(defaultTenant); for (const middleware of middlewareList) { expect(middleware).toBeCalled(); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index bf12c3510..700f1c0f1 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -24,10 +24,23 @@ import initRouter from '#src/routes/init.js'; import Libraries from './Libraries.js'; import Queries from './Queries.js'; import type TenantContext from './TenantContext.js'; +import { defaultTenant } from './consts.js'; +import { getTenantDatabaseDsn } from './utils.js'; export default class Tenant implements TenantContext { - static async create(id: string) { - const envSet = new EnvSet(); + static async create(id: string): Promise { + if (!EnvSet.values.isMultiTenancy) { + if (id !== defaultTenant) { + throw new Error( + `Trying to create a tenant instance with ID ${id} in single-tenancy mode. This is a no-op.` + ); + } + + return new Tenant(EnvSet.default, id); + } + + // In multi-tenancy mode, treat the default database URL as the management URL + const envSet = new EnvSet(await getTenantDatabaseDsn(EnvSet.default, id)); await envSet.load(); return new Tenant(envSet, id); diff --git a/packages/core/src/tenants/index.ts b/packages/core/src/tenants/index.ts index 4173dc4b8..22b3107f9 100644 --- a/packages/core/src/tenants/index.ts +++ b/packages/core/src/tenants/index.ts @@ -32,3 +32,4 @@ class TenantPool { export const tenantPool = new TenantPool(); export * from './consts.js'; +export * from './utils.js'; diff --git a/packages/core/src/tenants/utils.ts b/packages/core/src/tenants/utils.ts new file mode 100644 index 000000000..bf24df3fc --- /dev/null +++ b/packages/core/src/tenants/utils.ts @@ -0,0 +1,54 @@ +import { Tenants } from '@logto/schemas/models'; +import { isKeyInObject } from '@logto/shared'; +import { conditionalString } from '@silverhand/essentials'; +import { identifier, sql } from '@withtyped/postgres'; +import type { QueryClient } from '@withtyped/server'; +import { parseDsn, stringifyDsn } from 'slonik'; + +import type { EnvSet } from '#src/env-set/index.js'; + +/** + * 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. + */ +export const getTenantDatabaseDsn = async (defaultEnvSet: EnvSet, tenantId: string) => { + const { + tableName, + rawKeys: { id, dbUserPassword }, + } = Tenants; + + const { rows } = await defaultEnvSet.queryClient.query(sql` + select ${identifier(dbUserPassword)} + from ${identifier(tableName)} + where ${identifier(id)} = ${tenantId} + `); + const password = rows[0]?.db_user_password; + + if (!password || typeof password !== 'string') { + throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`); + } + + const options = parseDsn(defaultEnvSet.databaseUrl); + + return stringifyDsn({ ...options, username: `tenant_${tenantId}`, password }); +}; + +export const checkRowLevelSecurity = async (client: QueryClient) => { + const { rows } = await client.query(sql` + select tablename + from pg_catalog.pg_tables + where schemaname = current_schema() + and rowsecurity=false + `); + + if (rows.length > 0) { + throw new Error( + 'Row-level security has to be enforced on EVERY table when starting Logto in multi-tenancy mode.\n' + + `Found following table(s) without RLS: ${rows + .map((row) => conditionalString(isKeyInObject(row, 'tablename') && String(row.tablename))) + .join(', ')}\n\n` + + 'Did you forget to run `npm cli db multi-tenancy enable`?' + ); + } +}; diff --git a/packages/core/src/test-utils/env-set.ts b/packages/core/src/test-utils/env-set.ts deleted file mode 100644 index caa6358fa..000000000 --- a/packages/core/src/test-utils/env-set.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { EnvSet } from '#src/env-set/index.js'; - -/** FOR TEST PURPOSE ONLY, DON'T USE IN PROD. */ -export const envSetForTest = new EnvSet(); - -await envSetForTest.load(); diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index b24f4fcbb..b74698ebd 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -1,11 +1,11 @@ import { createMockPool, createMockQueryResult } from 'slonik'; +import { EnvSet } from '#src/env-set/index.js'; 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'; -import { envSetForTest } from './env-set.js'; import type { GrantMock } from './oidc-provider.js'; import { createMockProvider } from './oidc-provider.js'; import { MockQueryClient } from './query-client.js'; @@ -45,7 +45,7 @@ export type DeepPartial = T extends object export type Partial2 = { [key in keyof T]?: Partial }; export class MockTenant implements TenantContext { - public envSet = envSetForTest; + public envSet = EnvSet.default; public queries: Queries; public libraries: Libraries; public modelRouters = createModelRouters(new MockQueryClient()); diff --git a/packages/core/src/utils/connectors/loader.ts b/packages/core/src/utils/connectors/loader.ts index 58afa2777..8064f8048 100644 --- a/packages/core/src/utils/connectors/loader.ts +++ b/packages/core/src/utils/connectors/loader.ts @@ -2,18 +2,12 @@ import path from 'path'; import type { AllConnector, CreateConnector } from '@logto/connector-kit'; import connectorKitMeta from '@logto/connector-kit/package.json' assert { type: 'json' }; +import { isKeyInObject } from '@logto/shared'; import { satisfies } from 'semver'; const connectorKit = '@logto/connector-kit'; const { version: currentVersion } = connectorKitMeta; -const isKeyInObject = ( - object: unknown, - key: Key - // eslint-disable-next-line @typescript-eslint/ban-types -): object is object & Record => - object !== null && typeof object === 'object' && key in object; - const checkConnectorKitVersion = (dependencies: unknown) => { if (isKeyInObject(dependencies, connectorKit)) { const value = dependencies[connectorKit]; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index ac3c56631..69b2995a1 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -53,6 +53,6 @@ }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@withtyped/server": "^0.3.1" + "@withtyped/server": "^0.4.0" } } diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 429f81cab..8082e719b 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -81,7 +81,7 @@ "@logto/language-kit": "workspace:*", "@logto/phrases": "workspace:*", "@logto/phrases-ui": "workspace:*", - "@withtyped/server": "^0.3.1", + "@withtyped/server": "^0.4.0", "zod": "^3.20.2" } } diff --git a/packages/schemas/src/models/index.ts b/packages/schemas/src/models/index.ts index a06cc95d3..1d3899738 100644 --- a/packages/schemas/src/models/index.ts +++ b/packages/schemas/src/models/index.ts @@ -1 +1,2 @@ export * from './hooks.js'; +export * from './tenants.js'; diff --git a/packages/schemas/src/models/tenants.ts b/packages/schemas/src/models/tenants.ts new file mode 100644 index 000000000..abc8353ca --- /dev/null +++ b/packages/schemas/src/models/tenants.ts @@ -0,0 +1,9 @@ +import { createModel } from '@withtyped/server'; + +export const Tenants = createModel(/* sql */ ` + create table tenants ( + id varchar(32) not null, + db_user_password varchar(128) not null, + primary key (id) + ); +`); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 99230af64..8d0e61395 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './function.js'; +export * from './object.js'; export { default as findPackage } from './find-package.js'; diff --git a/packages/shared/src/utils/object.ts b/packages/shared/src/utils/object.ts new file mode 100644 index 000000000..18ed6355a --- /dev/null +++ b/packages/shared/src/utils/object.ts @@ -0,0 +1,5 @@ +export const isKeyInObject = ( + object: unknown, + key: Key +): object is object & Record => + object !== null && typeof object === 'object' && key in object; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 089418edb..69cbc48bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,8 +274,8 @@ importers: '@types/semver': ^7.3.12 '@types/sinon': ^10.0.13 '@types/supertest': ^2.0.11 - '@withtyped/postgres': ^0.3.1 - '@withtyped/server': ^0.3.1 + '@withtyped/postgres': ^0.4.0 + '@withtyped/server': ^0.4.0 chalk: ^5.0.0 clean-deep: ^3.4.0 copyfiles: ^2.4.1 @@ -333,8 +333,8 @@ importers: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 2.1.0 - '@withtyped/postgres': 0.3.1_@withtyped+server@0.3.1 - '@withtyped/server': 0.3.1 + '@withtyped/postgres': 0.4.0_@withtyped+server@0.4.0 + '@withtyped/server': 0.4.0 chalk: 5.1.2 clean-deep: 3.4.0 date-fns: 2.29.3 @@ -479,7 +479,7 @@ importers: '@types/jest': ^29.1.2 '@types/jest-environment-puppeteer': ^5.0.2 '@types/node': ^18.11.18 - '@withtyped/server': ^0.3.1 + '@withtyped/server': ^0.4.0 dotenv: ^16.0.0 eslint: ^8.21.0 got: ^12.5.3 @@ -493,7 +493,7 @@ importers: text-encoder: ^0.0.4 typescript: ^4.9.4 dependencies: - '@withtyped/server': 0.3.1 + '@withtyped/server': 0.4.0 devDependencies: '@jest/types': 29.1.2 '@logto/connector-kit': link:../toolkit/connector-kit @@ -583,7 +583,7 @@ importers: '@types/jest': ^29.1.2 '@types/node': ^18.11.18 '@types/pluralize': ^0.0.29 - '@withtyped/server': ^0.3.1 + '@withtyped/server': ^0.4.0 camelcase: ^7.0.0 eslint: ^8.21.0 jest: ^29.1.2 @@ -599,7 +599,7 @@ importers: '@logto/language-kit': link:../toolkit/language-kit '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui - '@withtyped/server': 0.3.1 + '@withtyped/server': 0.4.0 zod: 3.20.2 devDependencies: '@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4 @@ -4080,21 +4080,12 @@ packages: resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} dev: true - /@types/pg/8.6.5: - resolution: {integrity: sha512-tOkGtAqRVkHa/PVZicq67zuujI4Oorfglsr2IbKofDwBSysnaqSx7W1mDqFqdkGE6Fbgh+PZAl0r/BWON/mozw==} - dependencies: - '@types/node': 18.11.18 - pg-protocol: 1.5.0 - pg-types: 2.2.0 - dev: false - /@types/pg/8.6.6: resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==} dependencies: '@types/node': 18.11.18 pg-protocol: 1.5.0 pg-types: 2.2.0 - dev: true /@types/pluralize/0.0.29: resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==} @@ -4397,21 +4388,21 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@withtyped/postgres/0.3.1_@withtyped+server@0.3.1: - resolution: {integrity: sha512-+XP+kbmTKKpv/5Nf4KDVKfWp6kYGIyty3aUUnSrBY0KLdOUfesuPjFK6S7sNgbh+7pvk/iU48/3UDsjuy4m+SQ==} + /@withtyped/postgres/0.4.0_@withtyped+server@0.4.0: + resolution: {integrity: sha512-jzDdXhGNkIBeWlnEU3hft2CriyWgabI46a5n5T7faMUkHzjHlgIH4IscdT8Vq7n3YIdAC6ovFtQW8g6SNyVvlg==} peerDependencies: - '@withtyped/server': ^0.3.0 + '@withtyped/server': ^0.4.0 dependencies: - '@types/pg': 8.6.5 - '@withtyped/server': 0.3.1 + '@types/pg': 8.6.6 + '@withtyped/server': 0.4.0 '@withtyped/shared': 0.2.0 pg: 8.8.0 transitivePeerDependencies: - pg-native dev: false - /@withtyped/server/0.3.1: - resolution: {integrity: sha512-AI4QDHVTgv5GWPomWCgP5vqgVWaiby1vm56LBbSqe6r1DTPOZrySoxNoaE5XTQzYX1Jd3pzq1CsOd5AxkgCfpg==} + /@withtyped/server/0.4.0: + resolution: {integrity: sha512-72WUKDnhJl5FZurPUrvrwCcyIrj+U5Vq4vghmB/Lg+Bb9eTgSFbsaKujJtJNFor+1eSEDdCNNNUvOxfwZEz2JQ==} dependencies: '@withtyped/shared': 0.2.0 dev: false