mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor: use RLS
This commit is contained in:
parent
1e8c00c014
commit
99837b4e48
38 changed files with 429 additions and 170 deletions
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
@ -173,6 +173,10 @@ jobs:
|
||||||
run: node .scripts/compare-database.js fresh old
|
run: node .scripts/compare-database.js fresh old
|
||||||
# ** End **
|
# ** End **
|
||||||
|
|
||||||
|
- name: Check database
|
||||||
|
working-directory: ./fresh
|
||||||
|
run: node .scripts/check-database.js fresh
|
||||||
|
|
||||||
- name: Check alteration databases
|
- name: Check alteration databases
|
||||||
working-directory: ./fresh
|
working-directory: ./fresh
|
||||||
run: node .scripts/check-alterations-sequence.js
|
run: node .scripts/check-alterations-sequence.js
|
||||||
|
|
3
.scripts/check-database.js
Normal file
3
.scripts/check-database.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
throw new Error('not implemented');
|
||||||
|
|
||||||
|
// TODO: check tables have tenant_id
|
|
@ -1,102 +1,20 @@
|
||||||
import { readdir, readFile } from 'fs/promises';
|
import { logtoConfigGuards, LogtoOidcConfigKey } from '@logto/schemas';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { generateStandardId } from '@logto/core-kit';
|
|
||||||
import {
|
|
||||||
logtoConfigGuards,
|
|
||||||
LogtoOidcConfigKey,
|
|
||||||
managementResource,
|
|
||||||
defaultSignInExperience,
|
|
||||||
createDefaultAdminConsoleConfig,
|
|
||||||
createDemoAppApplication,
|
|
||||||
defaultRole,
|
|
||||||
managementResourceScope,
|
|
||||||
defaultRoleScopeRelation,
|
|
||||||
defaultTenant,
|
|
||||||
} from '@logto/schemas';
|
|
||||||
import { Hooks, Tenants } from '@logto/schemas/models';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import type { DatabasePool, DatabaseTransactionConnection } from 'slonik';
|
import type { DatabasePool, DatabaseTransactionConnection } from 'slonik';
|
||||||
import { sql } from 'slonik';
|
|
||||||
import { raw } from 'slonik-sql-tag-raw';
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js';
|
import { createPoolAndDatabaseIfNeeded } from '../../../database.js';
|
||||||
import {
|
import {
|
||||||
getRowsByKeys,
|
getRowsByKeys,
|
||||||
doesConfigsTableExist,
|
doesConfigsTableExist,
|
||||||
updateValueByKey,
|
updateValueByKey,
|
||||||
} from '../../../queries/logto-config.js';
|
} from '../../../queries/logto-config.js';
|
||||||
import { updateDatabaseTimestamp } from '../../../queries/system.js';
|
import { log, oraPromise } from '../../../utilities.js';
|
||||||
import { getPathInModule, log, oraPromise } from '../../../utilities.js';
|
|
||||||
import { getLatestAlterationTimestamp } from '../alteration/index.js';
|
import { getLatestAlterationTimestamp } from '../alteration/index.js';
|
||||||
import { getAlterationDirectory } from '../alteration/utils.js';
|
import { getAlterationDirectory } from '../alteration/utils.js';
|
||||||
import { oidcConfigReaders } from './oidc-config.js';
|
import { oidcConfigReaders } from './oidc-config.js';
|
||||||
|
import { createTables, seedTables } from './tables.js';
|
||||||
const getExplicitOrder = (query: string) => {
|
|
||||||
const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1];
|
|
||||||
|
|
||||||
return matched ? Number(matched) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const compareQuery = ([t1, q1]: [string, string], [t2, q2]: [string, string]) => {
|
|
||||||
const o1 = getExplicitOrder(q1);
|
|
||||||
const o2 = getExplicitOrder(q2);
|
|
||||||
|
|
||||||
if (o1 === undefined && o2 === undefined) {
|
|
||||||
return t1.localeCompare(t2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (o1 === undefined) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (o2 === undefined) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return o1 - o2;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTables = async (connection: DatabaseTransactionConnection) => {
|
|
||||||
const tableDirectory = getPathInModule('@logto/schemas', 'tables');
|
|
||||||
const directoryFiles = await readdir(tableDirectory);
|
|
||||||
const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql'));
|
|
||||||
const queries = await Promise.all(
|
|
||||||
tableFiles.map<Promise<[string, string]>>(async (file) => [
|
|
||||||
file,
|
|
||||||
await readFile(path.join(tableDirectory, file), 'utf8'),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const allQueries: Array<[string, string]> = [
|
|
||||||
[Hooks.tableName, Hooks.raw],
|
|
||||||
[Tenants.tableName, Tenants.raw],
|
|
||||||
...queries,
|
|
||||||
];
|
|
||||||
const sorted = allQueries.slice().sort(compareQuery);
|
|
||||||
|
|
||||||
for (const [, query] of sorted) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await connection.query(sql`${raw(query)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const seedTables = async (connection: DatabaseTransactionConnection, latestTimestamp: number) => {
|
|
||||||
await connection.query(insertInto(defaultTenant, 'tenants'));
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
connection.query(insertInto(managementResource, 'resources')),
|
|
||||||
connection.query(insertInto(managementResourceScope, 'scopes')),
|
|
||||||
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
|
|
||||||
connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
|
|
||||||
connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')),
|
|
||||||
connection.query(insertInto(defaultRole, 'roles')),
|
|
||||||
connection.query(insertInto(defaultRoleScopeRelation, 'roles_scopes')),
|
|
||||||
updateDatabaseTimestamp(connection, latestTimestamp),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => {
|
const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => {
|
||||||
const configGuard = z.object({
|
const configGuard = z.object({
|
||||||
|
|
127
packages/cli/src/commands/database/seed/tables.ts
Normal file
127
packages/cli/src/commands/database/seed/tables.ts
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
import { readdir, readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { generateStandardId } from '@logto/core-kit';
|
||||||
|
import {
|
||||||
|
managementResource,
|
||||||
|
defaultSignInExperience,
|
||||||
|
createDefaultAdminConsoleConfig,
|
||||||
|
createDemoAppApplication,
|
||||||
|
defaultRole,
|
||||||
|
managementResourceScope,
|
||||||
|
defaultRoleScopeRelation,
|
||||||
|
defaultTenantId,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { Hooks, Tenants } from '@logto/schemas/models';
|
||||||
|
import type { DatabaseTransactionConnection } from 'slonik';
|
||||||
|
import { sql } from 'slonik';
|
||||||
|
import { raw } from 'slonik-sql-tag-raw';
|
||||||
|
|
||||||
|
import { insertInto } from '../../../database.js';
|
||||||
|
import { getDatabaseName } from '../../../queries/database.js';
|
||||||
|
import { updateDatabaseTimestamp } from '../../../queries/system.js';
|
||||||
|
import { getPathInModule } from '../../../utilities.js';
|
||||||
|
import { createTenant } from './tenant.js';
|
||||||
|
|
||||||
|
const getExplicitOrder = (query: string) => {
|
||||||
|
const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1];
|
||||||
|
|
||||||
|
return matched ? Number(matched) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareQuery = ([t1, q1]: [string, string], [t2, q2]: [string, string]) => {
|
||||||
|
const o1 = getExplicitOrder(q1);
|
||||||
|
const o2 = getExplicitOrder(q2);
|
||||||
|
|
||||||
|
if (o1 === undefined && o2 === undefined) {
|
||||||
|
return t1.localeCompare(t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o1 === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o2 === undefined) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return o1 - o2;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Lifecycle = 'before_all' | 'after_all' | 'after_each';
|
||||||
|
|
||||||
|
const lifecycleNames: readonly string[] = Object.freeze([
|
||||||
|
'before_all',
|
||||||
|
'after_all',
|
||||||
|
'after_each',
|
||||||
|
] satisfies Lifecycle[]);
|
||||||
|
|
||||||
|
export const createTables = async (connection: DatabaseTransactionConnection) => {
|
||||||
|
const tableDirectory = getPathInModule('@logto/schemas', 'tables');
|
||||||
|
const directoryFiles = await readdir(tableDirectory);
|
||||||
|
const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql'));
|
||||||
|
const queries = await Promise.all(
|
||||||
|
tableFiles.map<Promise<[string, string]>>(async (file) => [
|
||||||
|
file,
|
||||||
|
await readFile(path.join(tableDirectory, file), 'utf8'),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const runLifecycleQuery = async (
|
||||||
|
lifecycle: Lifecycle,
|
||||||
|
parameters: { name?: string; database?: string } = {}
|
||||||
|
) => {
|
||||||
|
const query = queries.find(([file]) => file.slice(1, -4) === lifecycle)?.[1];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
await connection.query(
|
||||||
|
sql`${raw(
|
||||||
|
/* eslint-disable no-template-curly-in-string */
|
||||||
|
query
|
||||||
|
.replaceAll('${name}', parameters.name ?? '')
|
||||||
|
.replaceAll('${database}', parameters.database ?? '')
|
||||||
|
/* eslint-enable no-template-curly-in-string */
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allQueries: Array<[string, string]> = [
|
||||||
|
[Hooks.tableName, Hooks.raw],
|
||||||
|
[Tenants.tableName, Tenants.raw],
|
||||||
|
...queries.filter(([file]) => !lifecycleNames.includes(file.slice(1, -4))),
|
||||||
|
];
|
||||||
|
const sorted = allQueries.slice().sort(compareQuery);
|
||||||
|
const database = await getDatabaseName(connection, true);
|
||||||
|
|
||||||
|
await runLifecycleQuery('before_all', { database });
|
||||||
|
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
for (const [file, query] of sorted) {
|
||||||
|
await connection.query(sql`${raw(query)}`);
|
||||||
|
|
||||||
|
if (!query.includes('/* no_after_each */')) {
|
||||||
|
await runLifecycleQuery('after_each', { name: file.split('.')[0], database });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop */
|
||||||
|
|
||||||
|
await runLifecycleQuery('after_all', { database });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedTables = async (
|
||||||
|
connection: DatabaseTransactionConnection,
|
||||||
|
latestTimestamp: number
|
||||||
|
) => {
|
||||||
|
await createTenant(connection, defaultTenantId);
|
||||||
|
await Promise.all([
|
||||||
|
connection.query(insertInto(managementResource, 'resources')),
|
||||||
|
connection.query(insertInto(managementResourceScope, 'scopes')),
|
||||||
|
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
|
||||||
|
connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
|
||||||
|
connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')),
|
||||||
|
connection.query(insertInto(defaultRole, 'roles')),
|
||||||
|
connection.query(insertInto(defaultRoleScopeRelation, 'roles_scopes')),
|
||||||
|
updateDatabaseTimestamp(connection, latestTimestamp),
|
||||||
|
]);
|
||||||
|
};
|
23
packages/cli/src/commands/database/seed/tenant.ts
Normal file
23
packages/cli/src/commands/database/seed/tenant.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { generateStandardId } from '@logto/core-kit';
|
||||||
|
import type { TenantModel } from '@logto/schemas';
|
||||||
|
import type { DatabaseTransactionConnection } from 'slonik';
|
||||||
|
import { sql } from 'slonik';
|
||||||
|
import { raw } from 'slonik-sql-tag-raw';
|
||||||
|
|
||||||
|
import { insertInto } from '../../../database.js';
|
||||||
|
import { getDatabaseName } from '../../../queries/database.js';
|
||||||
|
|
||||||
|
export const createTenant = async (connection: DatabaseTransactionConnection, tenantId: string) => {
|
||||||
|
const database = await getDatabaseName(connection, true);
|
||||||
|
const parentRole = `logto_tenant_${database}`;
|
||||||
|
const role = `logto_tenant_${database}_${tenantId}`;
|
||||||
|
const password = generateStandardId(32);
|
||||||
|
const tenantModel: TenantModel = { id: tenantId, dbUser: role, dbUserPassword: password };
|
||||||
|
|
||||||
|
await connection.query(insertInto(tenantModel, 'tenants'));
|
||||||
|
await connection.query(sql`
|
||||||
|
create role ${sql.identifier([role])} with inherit login
|
||||||
|
password '${raw(password)}'
|
||||||
|
in role ${sql.identifier([parentRole])};
|
||||||
|
`);
|
||||||
|
};
|
10
packages/cli/src/queries/database.ts
Normal file
10
packages/cli/src/queries/database.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import type { CommonQueryMethods } from 'slonik';
|
||||||
|
import { sql } from 'slonik';
|
||||||
|
|
||||||
|
export const getDatabaseName = async (pool: CommonQueryMethods, normalized = false) => {
|
||||||
|
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
|
||||||
|
select current_database();
|
||||||
|
`);
|
||||||
|
|
||||||
|
return normalized ? currentDatabase.replaceAll('-', '_') : currentDatabase;
|
||||||
|
};
|
|
@ -34,8 +34,8 @@
|
||||||
"@logto/schemas": "workspace:*",
|
"@logto/schemas": "workspace:*",
|
||||||
"@logto/shared": "workspace:*",
|
"@logto/shared": "workspace:*",
|
||||||
"@silverhand/essentials": "2.1.0",
|
"@silverhand/essentials": "2.1.0",
|
||||||
"@withtyped/postgres": "^0.4.0",
|
"@withtyped/postgres": "^0.4.1",
|
||||||
"@withtyped/server": "^0.4.0",
|
"@withtyped/server": "^0.4.1",
|
||||||
"chalk": "^5.0.0",
|
"chalk": "^5.0.0",
|
||||||
"clean-deep": "^3.4.0",
|
"clean-deep": "^3.4.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
|
|
@ -27,6 +27,7 @@ const queryFunction = jest.fn();
|
||||||
|
|
||||||
const url = 'https://logto.gg';
|
const url = 'https://logto.gg';
|
||||||
const hook: InferModelType<ModelRouters['hook']['model']> = {
|
const hook: InferModelType<ModelRouters['hook']['model']> = {
|
||||||
|
tenantId: undefined,
|
||||||
id: 'foo',
|
id: 'foo',
|
||||||
event: HookEvent.PostSignIn,
|
event: HookEvent.PostSignIn,
|
||||||
config: { headers: { bar: 'baz' }, url, retries: 3 },
|
config: { headers: { bar: 'baz' }, url, retries: 3 },
|
||||||
|
|
|
@ -27,11 +27,7 @@ import { getTenantDatabaseDsn } from './utils.js';
|
||||||
|
|
||||||
export default class Tenant implements TenantContext {
|
export default class Tenant implements TenantContext {
|
||||||
static async create(id: string): Promise<Tenant> {
|
static async create(id: string): Promise<Tenant> {
|
||||||
if (!EnvSet.values.isDomainBasedMultiTenancy) {
|
// Treat the default database URL as the management URL
|
||||||
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));
|
const envSet = new EnvSet(await getTenantDatabaseDsn(EnvSet.default, id));
|
||||||
await envSet.load();
|
await envSet.load();
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { Systems } from '@logto/schemas';
|
||||||
import { Tenants } from '@logto/schemas/models';
|
import { Tenants } from '@logto/schemas/models';
|
||||||
import { isKeyInObject } from '@logto/shared';
|
import { isKeyInObject } from '@logto/shared';
|
||||||
import { conditionalString } from '@silverhand/essentials';
|
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||||
import { identifier, sql } from '@withtyped/postgres';
|
import { identifier, sql } from '@withtyped/postgres';
|
||||||
import type { QueryClient } from '@withtyped/server';
|
import type { QueryClient } from '@withtyped/server';
|
||||||
import { parseDsn, stringifyDsn } from 'slonik';
|
import { parseDsn, stringifyDsn } from 'slonik';
|
||||||
|
@ -15,23 +16,28 @@ import type { EnvSet } from '#src/env-set/index.js';
|
||||||
export const getTenantDatabaseDsn = async (defaultEnvSet: EnvSet, tenantId: string) => {
|
export const getTenantDatabaseDsn = async (defaultEnvSet: EnvSet, tenantId: string) => {
|
||||||
const {
|
const {
|
||||||
tableName,
|
tableName,
|
||||||
rawKeys: { id, dbUserPassword },
|
rawKeys: { id, dbUser, dbUserPassword },
|
||||||
} = Tenants;
|
} = Tenants;
|
||||||
|
|
||||||
const { rows } = await defaultEnvSet.queryClient.query(sql`
|
const { rows } = await defaultEnvSet.queryClient.query(sql`
|
||||||
select ${identifier(dbUserPassword)}
|
select ${identifier(dbUser)}, ${identifier(dbUserPassword)}
|
||||||
from ${identifier(tableName)}
|
from ${identifier(tableName)}
|
||||||
where ${identifier(id)} = ${tenantId}
|
where ${identifier(id)} = ${tenantId}
|
||||||
`);
|
`);
|
||||||
const password = rows[0]?.db_user_password;
|
|
||||||
|
|
||||||
if (!password || typeof password !== 'string') {
|
if (!rows[0]) {
|
||||||
throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`);
|
throw new Error(`Cannot find valid tenant credentials for ID ${tenantId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parseDsn(defaultEnvSet.databaseUrl);
|
const options = parseDsn(defaultEnvSet.databaseUrl);
|
||||||
|
const username = rows[0][dbUser];
|
||||||
|
const password = rows[0][dbUserPassword];
|
||||||
|
|
||||||
return stringifyDsn({ ...options, username: `tenant_${tenantId}`, password });
|
return stringifyDsn({
|
||||||
|
...options,
|
||||||
|
username: conditional(!username && String(username)),
|
||||||
|
password: conditional(!password && String(password)),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkRowLevelSecurity = async (client: QueryClient) => {
|
export const checkRowLevelSecurity = async (client: QueryClient) => {
|
||||||
|
@ -42,9 +48,11 @@ export const checkRowLevelSecurity = async (client: QueryClient) => {
|
||||||
and rowsecurity=false
|
and rowsecurity=false
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (
|
||||||
|
rows.some(({ tablename }) => tablename !== Systems.table && tablename !== Tenants.tableName)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Row-level security has to be enforced on EVERY table when starting Logto in multi-tenancy mode.\n' +
|
'Row-level security has to be enforced on EVERY business table when starting Logto.\n' +
|
||||||
`Found following table(s) without RLS: ${rows
|
`Found following table(s) without RLS: ${rows
|
||||||
.map((row) => conditionalString(isKeyInObject(row, 'tablename') && String(row.tablename)))
|
.map((row) => conditionalString(isKeyInObject(row, 'tablename') && String(row.tablename)))
|
||||||
.join(', ')}\n\n` +
|
.join(', ')}\n\n` +
|
||||||
|
|
|
@ -53,6 +53,6 @@
|
||||||
},
|
},
|
||||||
"prettier": "@silverhand/eslint-config/.prettierrc",
|
"prettier": "@silverhand/eslint-config/.prettierrc",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@withtyped/server": "^0.4.0"
|
"@withtyped/server": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
24
packages/schemas/README.md
Normal file
24
packages/schemas/README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# @logto/schemas
|
||||||
|
|
||||||
|
The central packages for all database schemas and their TypeScript definitions and utilities.
|
||||||
|
|
||||||
|
## Table init
|
||||||
|
|
||||||
|
The Logto CLI will pick up all necessary SQL queries in `tables/` and `src/models/` and run them in the following order:
|
||||||
|
|
||||||
|
1. Run `tables/_before_all.sql`
|
||||||
|
2. Run `tables/*.sql` with the snippet `/* init_order = <number> */` in ascending order of `<number>`
|
||||||
|
3. Run `tables/*.sql` without the `init_order` snippet in ascending order of filename (`tables/`) or table name (`src/models/`)
|
||||||
|
4. Run `tables/_after_all.sql`
|
||||||
|
|
||||||
|
Additional rules for step 2 and 3:
|
||||||
|
|
||||||
|
- If no snippet `/* no_after_each */` found, run `tables/_after_each.sql` after each SQL file
|
||||||
|
- Exclude lifecycle scripts `tables/_[lifecycle].sql` where `[lifecycle]` could be one of:
|
||||||
|
- `after_all`
|
||||||
|
- `after_each`
|
||||||
|
- `before_all`
|
||||||
|
|
||||||
|
In the `after_each` lifecycle script, you can use `${name}` to represent the current filename (`tables/`) or table name (`src/models/`).
|
||||||
|
|
||||||
|
In all lifecycle scripts, you can use `${database}` to represent the current database.
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { generateStandardId } from '@logto/core-kit';
|
||||||
|
import type { CommonQueryMethods } from 'slonik';
|
||||||
|
import { sql } from 'slonik';
|
||||||
|
import { raw } from 'slonik-sql-tag-raw';
|
||||||
|
|
||||||
|
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||||
|
|
||||||
|
const tables: string[] = [
|
||||||
|
'applications_roles',
|
||||||
|
'applications',
|
||||||
|
'connectors',
|
||||||
|
'custom_phrases',
|
||||||
|
'logs',
|
||||||
|
'logto_configs',
|
||||||
|
'oidc_model_instances',
|
||||||
|
'passcodes',
|
||||||
|
'resources',
|
||||||
|
'roles_scopes',
|
||||||
|
'roles',
|
||||||
|
'scopes',
|
||||||
|
'sign_in_experiences',
|
||||||
|
'users_roles',
|
||||||
|
'users',
|
||||||
|
'hooks',
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultTenantId = 'default';
|
||||||
|
|
||||||
|
const getId = (value: string) => sql.identifier([value]);
|
||||||
|
|
||||||
|
const getDatabaseName = async (pool: CommonQueryMethods) => {
|
||||||
|
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
|
||||||
|
select current_database();
|
||||||
|
`);
|
||||||
|
|
||||||
|
return currentDatabase.replaceAll('-', '_');
|
||||||
|
};
|
||||||
|
|
||||||
|
const alteration: AlterationScript = {
|
||||||
|
up: async (pool) => {
|
||||||
|
const database = await getDatabaseName(pool);
|
||||||
|
|
||||||
|
// Alter hooks table for multi-tenancy (missed before)
|
||||||
|
await pool.query(sql`
|
||||||
|
alter table hooks
|
||||||
|
add column tenant_id varchar(21) not null default 'default'
|
||||||
|
references tenants (id) on update cascade on delete cascade;
|
||||||
|
alter table hooks
|
||||||
|
alter column tenant_id drop default;
|
||||||
|
create index hooks__id on hooks (tenant_id, id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create role and setup privileges
|
||||||
|
const baseRole = `logto_tenant_${database}`;
|
||||||
|
const baseRoleId = getId(baseRole);
|
||||||
|
await pool.query(sql`
|
||||||
|
create role ${baseRoleId} noinherit;
|
||||||
|
|
||||||
|
grant select, insert, update, delete
|
||||||
|
on all tables
|
||||||
|
in schema public
|
||||||
|
to ${baseRoleId};
|
||||||
|
|
||||||
|
revoke all privileges
|
||||||
|
on table tenants
|
||||||
|
from ${baseRoleId};
|
||||||
|
|
||||||
|
revoke all privileges
|
||||||
|
on table systems
|
||||||
|
from ${baseRoleId};
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add db_user column to tenants table
|
||||||
|
await pool.query(sql`
|
||||||
|
alter table tenants
|
||||||
|
add column db_user varchar(128),
|
||||||
|
add constraint tenants__db_user
|
||||||
|
unique (db_user);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Enable RLS
|
||||||
|
await Promise.all(
|
||||||
|
tables.map(async (tableName) =>
|
||||||
|
pool.query(sql`
|
||||||
|
alter table ${getId(tableName)} enable row level security;
|
||||||
|
|
||||||
|
create policy ${getId(`${tableName}_tenant_id`)} on ${getId(tableName)}
|
||||||
|
to ${baseRoleId}
|
||||||
|
using (tenant_id = (select id from tenants where db_user = current_user));
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create database role for default tenant
|
||||||
|
const role = `logto_tenant_${database}_${defaultTenantId}`;
|
||||||
|
const password = generateStandardId(32);
|
||||||
|
|
||||||
|
await pool.query(sql`
|
||||||
|
update tenants
|
||||||
|
set db_user=${role}, db_user_password=${password}
|
||||||
|
where id=${defaultTenantId};
|
||||||
|
`);
|
||||||
|
await pool.query(sql`
|
||||||
|
create role ${sql.identifier([role])} with inherit login
|
||||||
|
password '${raw(password)}'
|
||||||
|
in role ${sql.identifier([baseRole])};
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
down: async (pool) => {
|
||||||
|
const database = await getDatabaseName(pool);
|
||||||
|
const baseRoleId = getId(`logto_tenant_${database}`);
|
||||||
|
const role = `logto_tenant_${database}_${defaultTenantId}`;
|
||||||
|
|
||||||
|
// Disable RLS
|
||||||
|
await Promise.all(
|
||||||
|
tables.map(async (tableName) =>
|
||||||
|
pool.query(sql`
|
||||||
|
drop policy ${getId(`${tableName}_tenant_id`)} on ${getId(tableName)};
|
||||||
|
alter table ${getId(tableName)} disable row level security;
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop role
|
||||||
|
await pool.query(sql`
|
||||||
|
drop role ${getId(role)};
|
||||||
|
|
||||||
|
revoke all privileges
|
||||||
|
on all tables
|
||||||
|
in schema public
|
||||||
|
from ${baseRoleId};
|
||||||
|
|
||||||
|
drop role ${baseRoleId};
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop db_user column from tenants table
|
||||||
|
await pool.query(sql`
|
||||||
|
alter table tenants
|
||||||
|
drop column db_user;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('3');
|
||||||
|
// Revert hooks table from multi-tenancy
|
||||||
|
await pool.query(sql`
|
||||||
|
drop index hooks__id;
|
||||||
|
|
||||||
|
alter table hooks
|
||||||
|
drop column tenant_id;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default alteration;
|
|
@ -83,7 +83,7 @@
|
||||||
"@logto/language-kit": "workspace:*",
|
"@logto/language-kit": "workspace:*",
|
||||||
"@logto/phrases": "workspace:*",
|
"@logto/phrases": "workspace:*",
|
||||||
"@logto/phrases-ui": "workspace:*",
|
"@logto/phrases-ui": "workspace:*",
|
||||||
"@withtyped/server": "^0.4.0",
|
"@withtyped/server": "^0.4.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,15 +45,20 @@ export const hookConfigGuard: z.ZodType<HookConfig> = z.object({
|
||||||
|
|
||||||
export const Hooks = createModel(/* sql */ `
|
export const Hooks = createModel(/* sql */ `
|
||||||
create table hooks (
|
create table hooks (
|
||||||
id varchar(32) not null,
|
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,
|
event varchar(128) not null,
|
||||||
config jsonb /* @use HookConfig */ not null,
|
config jsonb /* @use HookConfig */ not null,
|
||||||
created_at timestamptz not null default(now()),
|
created_at timestamptz not null default(now()),
|
||||||
primary key (id)
|
primary key (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create index hooks__event on hooks (event);
|
create index hooks__id on hooks (tenant_id, id);
|
||||||
|
|
||||||
|
create index hooks__event on hooks (tenant_id, event);
|
||||||
`)
|
`)
|
||||||
|
.extend('tenantId', z.string().optional())
|
||||||
.extend('id', { default: () => generateStandardId(), readonly: true })
|
.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('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working.
|
||||||
.extend('config', hookConfigGuard);
|
.extend('config', hookConfigGuard);
|
||||||
|
|
|
@ -4,7 +4,12 @@ export const Tenants = createModel(/* sql */ `
|
||||||
/* init_order = 0 */
|
/* init_order = 0 */
|
||||||
create table tenants (
|
create table tenants (
|
||||||
id varchar(21) not null,
|
id varchar(21) not null,
|
||||||
|
db_user varchar(128),
|
||||||
db_user_password varchar(128),
|
db_user_password varchar(128),
|
||||||
primary key (id)
|
primary key (id),
|
||||||
|
constraint tenants__db_user
|
||||||
|
unique (db_user)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* no_after_each */
|
||||||
`);
|
`);
|
||||||
|
|
|
@ -4,8 +4,4 @@ import type { Tenants } from '../models/tenants.js';
|
||||||
|
|
||||||
export const defaultTenantId = 'default';
|
export const defaultTenantId = 'default';
|
||||||
export const adminTenantId = 'admin';
|
export const adminTenantId = 'admin';
|
||||||
|
export type TenantModel = InferModelType<typeof Tenants>;
|
||||||
export const defaultTenant: InferModelType<typeof Tenants> = {
|
|
||||||
id: defaultTenantId,
|
|
||||||
dbUserPassword: null,
|
|
||||||
};
|
|
||||||
|
|
14
packages/schemas/tables/_after_all.sql
Normal file
14
packages/schemas/tables/_after_all.sql
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/* This SQL will run after all other queries. */
|
||||||
|
|
||||||
|
grant select, insert, update, delete
|
||||||
|
on all tables
|
||||||
|
in schema public
|
||||||
|
to logto_tenant_${database};
|
||||||
|
|
||||||
|
revoke all privileges
|
||||||
|
on table tenants
|
||||||
|
from logto_tenant_${database};
|
||||||
|
|
||||||
|
revoke all privileges
|
||||||
|
on table systems
|
||||||
|
from logto_tenant_${database};
|
10
packages/schemas/tables/_after_each.sql
Normal file
10
packages/schemas/tables/_after_each.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/* This SQL will run after each query files except lifecycle scripts and files that explicitly exclude `after_each`. */
|
||||||
|
|
||||||
|
create trigger set_tenant_id before insert on ${name}
|
||||||
|
for each row execute procedure set_tenant_id();
|
||||||
|
|
||||||
|
alter table ${name} enable row level security;
|
||||||
|
|
||||||
|
create policy ${name}_tenant_id on ${name}
|
||||||
|
to logto_tenant_${database}
|
||||||
|
using (tenant_id = (select id from tenants where db_user = current_user));
|
3
packages/schemas/tables/_before_all.sql
Normal file
3
packages/schemas/tables/_before_all.sql
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/* This SQL will run before all other queries. */
|
||||||
|
|
||||||
|
create role logto_tenant_${database} noinherit;
|
|
@ -12,3 +12,5 @@ $$ begin
|
||||||
|
|
||||||
return new;
|
return new;
|
||||||
end; $$ language plpgsql;
|
end; $$ language plpgsql;
|
||||||
|
|
||||||
|
/* no_after_each */
|
||||||
|
|
|
@ -18,6 +18,3 @@ create table applications (
|
||||||
|
|
||||||
create index applications__id
|
create index applications__id
|
||||||
on applications (tenant_id, id);
|
on applications (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on applications
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -13,6 +13,3 @@ create table applications_roles (
|
||||||
|
|
||||||
create index applications_roles__id
|
create index applications_roles__id
|
||||||
on applications_roles (tenant_id, id);
|
on applications_roles (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on applications_roles
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -12,6 +12,3 @@ create table connectors (
|
||||||
|
|
||||||
create index connectors__id
|
create index connectors__id
|
||||||
on connectors (tenant_id, id);
|
on connectors (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on connectors
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -11,6 +11,3 @@ create table custom_phrases (
|
||||||
|
|
||||||
create index custom_phrases__id
|
create index custom_phrases__id
|
||||||
on custom_phrases (tenant_id, id);
|
on custom_phrases (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on custom_phrases
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -19,6 +19,3 @@ create index logs__user_id
|
||||||
|
|
||||||
create index logs__application_id
|
create index logs__application_id
|
||||||
on logs (tenant_id, (payload->>'application_id') nulls last);
|
on logs (tenant_id, (payload->>'application_id') nulls last);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on logs
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -5,6 +5,3 @@ create table logto_configs (
|
||||||
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
|
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
|
||||||
primary key (tenant_id, key)
|
primary key (tenant_id, key)
|
||||||
);
|
);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on logto_configs
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -31,6 +31,3 @@ create index oidc_model_instances__model_name_payload_grant_id
|
||||||
model_name,
|
model_name,
|
||||||
(payload->>'grantId')
|
(payload->>'grantId')
|
||||||
);
|
);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on oidc_model_instances
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -24,6 +24,3 @@ create index passcodes__email_type
|
||||||
|
|
||||||
create index passcodes__phone_type
|
create index passcodes__phone_type
|
||||||
on passcodes (tenant_id, phone, type);
|
on passcodes (tenant_id, phone, type);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on passcodes
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -14,6 +14,3 @@ create table resources (
|
||||||
|
|
||||||
create index resources__id
|
create index resources__id
|
||||||
on resources (tenant_id, id);
|
on resources (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on resources
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -13,6 +13,3 @@ create table roles (
|
||||||
|
|
||||||
create index roles__id
|
create index roles__id
|
||||||
on roles (tenant_id, id);
|
on roles (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on roles
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -13,6 +13,3 @@ create table roles_scopes (
|
||||||
|
|
||||||
create index roles_scopes__id
|
create index roles_scopes__id
|
||||||
on roles_scopes (tenant_id, id);
|
on roles_scopes (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on roles_scopes
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -16,6 +16,3 @@ create table scopes (
|
||||||
|
|
||||||
create index scopes__id
|
create index scopes__id
|
||||||
on scopes (tenant_id, id);
|
on scopes (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on scopes
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -17,6 +17,3 @@ create table sign_in_experiences (
|
||||||
|
|
||||||
create index sign_in_experiences__id
|
create index sign_in_experiences__id
|
||||||
on sign_in_experiences (tenant_id, id);
|
on sign_in_experiences (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on sign_in_experiences
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -3,3 +3,5 @@ create table systems (
|
||||||
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
|
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
|
||||||
primary key (key)
|
primary key (key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* no_after_each */
|
||||||
|
|
|
@ -27,6 +27,3 @@ create index users__id
|
||||||
|
|
||||||
create index users__name
|
create index users__name
|
||||||
on users (tenant_id, name);
|
on users (tenant_id, name);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on users
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -13,6 +13,3 @@ create table users_roles (
|
||||||
|
|
||||||
create index users_roles__id
|
create index users_roles__id
|
||||||
on users_roles (tenant_id, id);
|
on users_roles (tenant_id, id);
|
||||||
|
|
||||||
create trigger set_tenant_id before insert on users_roles
|
|
||||||
for each row execute procedure set_tenant_id();
|
|
||||||
|
|
|
@ -276,8 +276,8 @@ importers:
|
||||||
'@types/semver': ^7.3.12
|
'@types/semver': ^7.3.12
|
||||||
'@types/sinon': ^10.0.13
|
'@types/sinon': ^10.0.13
|
||||||
'@types/supertest': ^2.0.11
|
'@types/supertest': ^2.0.11
|
||||||
'@withtyped/postgres': ^0.4.0
|
'@withtyped/postgres': ^0.4.1
|
||||||
'@withtyped/server': ^0.4.0
|
'@withtyped/server': ^0.4.1
|
||||||
chalk: ^5.0.0
|
chalk: ^5.0.0
|
||||||
clean-deep: ^3.4.0
|
clean-deep: ^3.4.0
|
||||||
copyfiles: ^2.4.1
|
copyfiles: ^2.4.1
|
||||||
|
@ -335,8 +335,8 @@ importers:
|
||||||
'@logto/schemas': link:../schemas
|
'@logto/schemas': link:../schemas
|
||||||
'@logto/shared': link:../shared
|
'@logto/shared': link:../shared
|
||||||
'@silverhand/essentials': 2.1.0
|
'@silverhand/essentials': 2.1.0
|
||||||
'@withtyped/postgres': 0.4.0_@withtyped+server@0.4.0
|
'@withtyped/postgres': 0.4.1_@withtyped+server@0.4.1
|
||||||
'@withtyped/server': 0.4.0
|
'@withtyped/server': 0.4.1
|
||||||
chalk: 5.1.2
|
chalk: 5.1.2
|
||||||
clean-deep: 3.4.0
|
clean-deep: 3.4.0
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
|
@ -481,7 +481,7 @@ importers:
|
||||||
'@types/jest': ^29.1.2
|
'@types/jest': ^29.1.2
|
||||||
'@types/jest-environment-puppeteer': ^5.0.2
|
'@types/jest-environment-puppeteer': ^5.0.2
|
||||||
'@types/node': ^18.11.18
|
'@types/node': ^18.11.18
|
||||||
'@withtyped/server': ^0.4.0
|
'@withtyped/server': ^0.4.1
|
||||||
dotenv: ^16.0.0
|
dotenv: ^16.0.0
|
||||||
eslint: ^8.21.0
|
eslint: ^8.21.0
|
||||||
got: ^12.5.3
|
got: ^12.5.3
|
||||||
|
@ -495,7 +495,7 @@ importers:
|
||||||
text-encoder: ^0.0.4
|
text-encoder: ^0.0.4
|
||||||
typescript: ^4.9.4
|
typescript: ^4.9.4
|
||||||
dependencies:
|
dependencies:
|
||||||
'@withtyped/server': 0.4.0
|
'@withtyped/server': 0.4.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@jest/types': 29.1.2
|
'@jest/types': 29.1.2
|
||||||
'@logto/connector-kit': link:../toolkit/connector-kit
|
'@logto/connector-kit': link:../toolkit/connector-kit
|
||||||
|
@ -585,7 +585,7 @@ importers:
|
||||||
'@types/jest': ^29.1.2
|
'@types/jest': ^29.1.2
|
||||||
'@types/node': ^18.11.18
|
'@types/node': ^18.11.18
|
||||||
'@types/pluralize': ^0.0.29
|
'@types/pluralize': ^0.0.29
|
||||||
'@withtyped/server': ^0.4.0
|
'@withtyped/server': ^0.4.1
|
||||||
camelcase: ^7.0.0
|
camelcase: ^7.0.0
|
||||||
eslint: ^8.21.0
|
eslint: ^8.21.0
|
||||||
jest: ^29.1.2
|
jest: ^29.1.2
|
||||||
|
@ -603,7 +603,7 @@ importers:
|
||||||
'@logto/language-kit': link:../toolkit/language-kit
|
'@logto/language-kit': link:../toolkit/language-kit
|
||||||
'@logto/phrases': link:../phrases
|
'@logto/phrases': link:../phrases
|
||||||
'@logto/phrases-ui': link:../phrases-ui
|
'@logto/phrases-ui': link:../phrases-ui
|
||||||
'@withtyped/server': 0.4.0
|
'@withtyped/server': 0.4.1
|
||||||
zod: 3.20.2
|
zod: 3.20.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
|
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
|
||||||
|
@ -4463,21 +4463,21 @@ packages:
|
||||||
eslint-visitor-keys: 3.3.0
|
eslint-visitor-keys: 3.3.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@withtyped/postgres/0.4.0_@withtyped+server@0.4.0:
|
/@withtyped/postgres/0.4.1_@withtyped+server@0.4.1:
|
||||||
resolution: {integrity: sha512-jzDdXhGNkIBeWlnEU3hft2CriyWgabI46a5n5T7faMUkHzjHlgIH4IscdT8Vq7n3YIdAC6ovFtQW8g6SNyVvlg==}
|
resolution: {integrity: sha512-UZtwUieJyj3tHxGgiskBPFefpkFZgv7yzlXFMmuyG2NNu6P8mFnCSp4vuycmGEJnYRLFrxDvLhjx1qpVlD3k6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@withtyped/server': ^0.4.0
|
'@withtyped/server': ^0.4.1
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/pg': 8.6.6
|
'@types/pg': 8.6.6
|
||||||
'@withtyped/server': 0.4.0
|
'@withtyped/server': 0.4.1
|
||||||
'@withtyped/shared': 0.2.0
|
'@withtyped/shared': 0.2.0
|
||||||
pg: 8.8.0
|
pg: 8.8.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- pg-native
|
- pg-native
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@withtyped/server/0.4.0:
|
/@withtyped/server/0.4.1:
|
||||||
resolution: {integrity: sha512-72WUKDnhJl5FZurPUrvrwCcyIrj+U5Vq4vghmB/Lg+Bb9eTgSFbsaKujJtJNFor+1eSEDdCNNNUvOxfwZEz2JQ==}
|
resolution: {integrity: sha512-QMhFntmF2o/e9dEJi84+RL+BySzV6+uayY1dL372OmYCcYftPUYYCctvpTaz5eWUipqV/hL4FaEpK1YVEKYPHQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@withtyped/shared': 0.2.0
|
'@withtyped/shared': 0.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
Loading…
Reference in a new issue