0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat: init multi-tenancy environment (#2929)

This commit is contained in:
Gao Sun 2023-01-18 20:38:05 +08:00 committed by GitHub
parent 4944062f1f
commit 95a44929a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 210 additions and 83 deletions

View file

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

View file

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

View file

@ -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<typeof loadEnvValues> = loadEnvValues();
static default = new EnvSet(EnvSet.values.dbUrl);
static get isTest() {
return this.values.isTest;
}
@ -63,7 +102,7 @@ export class EnvSet {
#queryClient: Optional<QueryClient<PostgreSql>>;
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
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();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Tenant> {
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);

View file

@ -32,3 +32,4 @@ class TenantPool {
export const tenantPool = new TenantPool();
export * from './consts.js';
export * from './utils.js';

View file

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

View file

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

View file

@ -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> = T extends object
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
export class MockTenant implements TenantContext {
public envSet = envSetForTest;
public envSet = EnvSet.default;
public queries: Queries;
public libraries: Libraries;
public modelRouters = createModelRouters(new MockQueryClient());

View file

@ -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 = <Key extends string>(
object: unknown,
key: Key
// eslint-disable-next-line @typescript-eslint/ban-types
): object is object & Record<Key, unknown> =>
object !== null && typeof object === 'object' && key in object;
const checkConnectorKitVersion = (dependencies: unknown) => {
if (isKeyInObject(dependencies, connectorKit)) {
const value = dependencies[connectorKit];

View file

@ -53,6 +53,6 @@
},
"prettier": "@silverhand/eslint-config/.prettierrc",
"dependencies": {
"@withtyped/server": "^0.3.1"
"@withtyped/server": "^0.4.0"
}
}

View file

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

View file

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

View file

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

View file

@ -1,2 +1,3 @@
export * from './function.js';
export * from './object.js';
export { default as findPackage } from './find-package.js';

View file

@ -0,0 +1,5 @@
export const isKeyInObject = <Key extends string>(
object: unknown,
key: Key
): object is object & Record<Key, unknown> =>
object !== null && typeof object === 'object' && key in object;

39
pnpm-lock.yaml generated
View file

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