diff --git a/packages/cli/package.json b/packages/cli/package.json index 08cac6beb..7f04774b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "precommit": "lint-staged", "build": "rimraf lib && tsc", "start": "node .", - "dev": "ts-node src/index.ts", + "dev": "ts-node --files src/index.ts", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build" @@ -34,6 +34,7 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { + "@logto/schemas": "^1.0.0-beta.10", "chalk": "^4.1.2", "find-up": "^5.0.0", "got": "^11.8.2", @@ -41,6 +42,8 @@ "inquirer": "^8.2.2", "ora": "^5.0.0", "semver": "^7.3.7", + "slonik": "^30.0.0", + "slonik-interceptor-preset": "^1.2.10", "tar": "^6.1.11", "yargs": "^17.6.0", "zod": "^3.18.0" diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index c81899e46..dced88bc0 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -1,12 +1,14 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; +import { getKey, setKey } from './key'; import { getUrl, setUrl } from './url'; const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', - builder: (yargs) => yargs.command(getUrl).command(setUrl).strict(), + builder: (yargs) => + yargs.command(getUrl).command(setUrl).command(getKey).command(setKey).demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/key.ts b/packages/cli/src/commands/database/key.ts new file mode 100644 index 000000000..ebf82f716 --- /dev/null +++ b/packages/cli/src/commands/database/key.ts @@ -0,0 +1,96 @@ +import { logtoConfigGuards, LogtoConfigKey, logtoConfigKeys } from '@logto/schemas'; +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../database'; +import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; +import { deduplicate, log } from '../../utilities'; + +const validKeysDisplay = chalk.green(logtoConfigKeys.join(', ')); + +type ValidateKeysFunction = { + (keys: string[]): asserts keys is LogtoConfigKey[]; + (key: string): asserts key is LogtoConfigKey; +}; + +const validateKeys: ValidateKeysFunction = (keys) => { + const invalidKey = (Array.isArray(keys) ? keys : [keys]).find( + // Using `.includes()` will result a type error + // eslint-disable-next-line unicorn/prefer-includes + (key) => !logtoConfigKeys.some((element) => element === key) + ); + + if (invalidKey) { + log.error( + `Invalid config key ${chalk.red(invalidKey)} found, expected one of ${validKeysDisplay}` + ); + } +}; + +export const getKey: CommandModule = { + command: 'get-key [keys...]', + describe: 'Get config value(s) of the given key(s) in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('keys', { + describe: 'The additional keys to get from database', + type: 'string', + array: true, + default: [], + }), + handler: async ({ key, keys }) => { + const queryKeys = deduplicate([key, ...keys]); + validateKeys(queryKeys); + + const pool = await createPoolFromConfig(); + const { rows } = await getRowsByKeys(pool, queryKeys); + await pool.end(); + + console.log( + queryKeys + .map((currentKey) => { + const value = rows.find(({ key }) => currentKey === key)?.value; + + return ( + chalk.magenta(currentKey) + + '=' + + (value === undefined ? chalk.gray(value) : chalk.green(JSON.stringify(value))) + ); + }) + .join('\n') + ); + }, +}; + +export const setKey: CommandModule = { + command: 'set-key ', + describe: 'Set config value of the given key in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('value', { + describe: 'The value to set, should be a valid JSON string', + type: 'string', + demandOption: true, + }), + handler: async ({ key, value }) => { + validateKeys(key); + + const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); + + const pool = await createPoolFromConfig(); + await updateValueByKey(pool, key, guarded); + await pool.end(); + + log.info(`Update ${chalk.green(key)} succeeded`); + }, +}; diff --git a/packages/cli/src/commands/database/url.ts b/packages/cli/src/commands/database/url.ts index 4d9751fa4..f9ee95a18 100644 --- a/packages/cli/src/commands/database/url.ts +++ b/packages/cli/src/commands/database/url.ts @@ -11,7 +11,7 @@ export const getUrl: CommandModule = { }, }; -export const setUrl: CommandModule, { url: string }> = { +export const setUrl: CommandModule = { command: 'set-url ', describe: 'Set database URL and save to config file', builder: (yargs) => diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 7d95d9be4..6c8a6d6e5 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -123,7 +123,7 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } ); }; -const install: CommandModule, { path?: string; silent?: boolean }> = { +const install: CommandModule = { command: ['init', 'i', 'install'], describe: 'Download and run the latest Logto release', builder: (yargs) => diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts new file mode 100644 index 000000000..72661780d --- /dev/null +++ b/packages/cli/src/database.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import { createPool, IdentifierSqlToken, sql } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; + +import { getConfig } from './config'; +import { log } from './utilities'; + +export const createPoolFromConfig = async () => { + const { databaseUrl } = await getConfig(); + + if (!databaseUrl) { + log.error( + `No database URL configured. Set one via ${chalk.green('database set-url')} command first.` + ); + } + + return createPool(databaseUrl, { + interceptors: createInterceptors(), + }); +}; + +// TODO: Move database utils to `core-kit` +export type Table = { table: string; fields: Record }; +export type FieldIdentifiers = { + [key in Key]: IdentifierSqlToken; +}; + +export const convertToIdentifiers = ({ table, fields }: T, withPrefix = false) => { + const fieldsIdentifiers = Object.entries(fields).map< + [keyof T['fields'], IdentifierSqlToken] + >(([key, value]) => [key, sql.identifier(withPrefix ? [table, value] : [value])]); + + return { + table: sql.identifier([table]), + // Key value inferred from the original fields directly + // eslint-disable-next-line no-restricted-syntax + fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers, + }; +}; diff --git a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts new file mode 100644 index 000000000..5e24372aa --- /dev/null +++ b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts @@ -0,0 +1,10 @@ +declare module 'slonik-interceptor-preset' { + import { Interceptor } from 'slonik'; + + export const createInterceptors: (config?: { + benchmarkQueries: boolean; + logQueries: boolean; + normaliseQueries: boolean; + transformFieldNames: boolean; + }) => readonly Interceptor[]; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4628e575f..e25fd5f84 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,7 +8,7 @@ void yargs(hideBin(process.argv)) .command(install) .command(database) .demandCommand(1) - .showHelpOnFail(true) + .showHelpOnFail(false) .strict() .parserConfiguration({ 'dot-notation': false, diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts new file mode 100644 index 000000000..ae3553a8a --- /dev/null +++ b/packages/cli/src/queries/logto-config.ts @@ -0,0 +1,26 @@ +import { LogtoConfig, logtoConfigGuards, LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; +import { DatabasePool, sql } from 'slonik'; +import { z } from 'zod'; + +import { convertToIdentifiers } from '../database'; + +const { table, fields } = convertToIdentifiers(LogtoConfigs); + +export const getRowsByKeys = async (pool: DatabasePool, keys: LogtoConfigKey[]) => + pool.query(sql` + select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} + where ${fields.key} in (${sql.join(keys, sql`,`)}) + `); + +export const updateValueByKey = async ( + pool: DatabasePool, + key: T, + value: z.infer +) => + pool.query( + sql` + insert into ${table} (${fields.key}, ${fields.value}) + values (${key}, ${sql.jsonb(value)}) + on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} + ` + ); diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index aa8f47206..ba79fed67 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -15,7 +15,7 @@ export const safeExecSync = (command: string) => { type Log = Readonly<{ info: typeof console.log; warn: typeof console.log; - error: typeof console.log; + error: (...args: Parameters) => never; }>; export const log: Log = Object.freeze({ @@ -72,3 +72,5 @@ export const downloadFile = async (url: string, destination: string) => { // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function export const noop = () => {}; + +export const deduplicate = (array: T[]) => [...new Set(array)]; diff --git a/packages/core/src/alteration/constants.ts b/packages/core/src/alteration/constants.ts index 4c419c472..76d18ba7d 100644 --- a/packages/core/src/alteration/constants.ts +++ b/packages/core/src/alteration/constants.ts @@ -1,4 +1,6 @@ -export const alterationStateKey = 'alterationState'; +import { LogtoConfigKey } from '@logto/schemas'; + +export const alterationStateKey: LogtoConfigKey = 'alterationState'; export const logtoConfigsTableFilePath = 'node_modules/@logto/schemas/tables/logto_configs.sql'; export const alterationFilesDirectorySource = 'node_modules/@logto/schemas/alterations'; export const alterationFilesDirectory = 'alterations/'; diff --git a/packages/core/src/alteration/index.test.ts b/packages/core/src/alteration/index.test.ts index bbc7432c4..6098fe329 100644 --- a/packages/core/src/alteration/index.test.ts +++ b/packages/core/src/alteration/index.test.ts @@ -115,7 +115,7 @@ describe('createLogtoConfigsTable()', () => { describe('updateDatabaseTimestamp()', () => { const expectSql = sql` insert into ${table} (${fields.key}, ${fields.value}) - values ($1, $2) + values ($1, $2::jsonb) on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} `; const updatedAt = '2022-09-21T06:32:46.583Z'; diff --git a/packages/core/src/alteration/index.ts b/packages/core/src/alteration/index.ts index ae6382ae2..e593faaf0 100644 --- a/packages/core/src/alteration/index.ts +++ b/packages/core/src/alteration/index.ts @@ -2,12 +2,8 @@ import { existsSync } from 'fs'; import { readdir, readFile } from 'fs/promises'; import path from 'path'; -import { LogtoConfig, LogtoConfigs } from '@logto/schemas'; -import { - AlterationScript, - AlterationState, - alterationStateGuard, -} from '@logto/schemas/lib/types/alteration'; +import { LogtoConfig, LogtoConfigs, AlterationState, alterationStateGuard } from '@logto/schemas'; +import { AlterationScript } from '@logto/schemas/lib/types/alteration'; import { conditionalString } from '@silverhand/essentials'; import chalk from 'chalk'; import { copy, remove } from 'fs-extra'; @@ -70,7 +66,7 @@ export const updateDatabaseTimestamp = async (pool: DatabasePool, timestamp?: nu await pool.query( sql` insert into ${table} (${fields.key}, ${fields.value}) - values (${alterationStateKey}, ${JSON.stringify(value)}) + values (${alterationStateKey}, ${sql.jsonb(value)}) on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} ` ); diff --git a/packages/schemas/src/types/alteration.ts b/packages/schemas/src/types/alteration.ts index 70c189ed0..7439b3eda 100644 --- a/packages/schemas/src/types/alteration.ts +++ b/packages/schemas/src/types/alteration.ts @@ -1,12 +1,4 @@ import type { DatabaseTransactionConnection } from 'slonik'; -import { z } from 'zod'; - -export const alterationStateGuard = z.object({ - timestamp: z.number(), - updatedAt: z.string().optional(), -}); - -export type AlterationState = z.infer; export type AlterationScript = { up: (connection: DatabaseTransactionConnection) => Promise; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index c22dd0bc7..9bb6fd003 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -2,3 +2,4 @@ export * from './connector'; export * from './log'; export * from './oidc-config'; export * from './user'; +export * from './logto-config'; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts new file mode 100644 index 000000000..2cdff3f17 --- /dev/null +++ b/packages/schemas/src/types/logto-config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +// Alteration state +export const alterationStateGuard = z.object({ + timestamp: z.number(), + updatedAt: z.string().optional(), +}); + +export type AlterationState = z.infer; + +// Logto OIDC config +export const logtoOidcConfigGuard = z.object({ + privateKeys: z.string().array().optional(), + cookieKeys: z.string().array().optional(), + refreshTokenReuseInterval: z.number().gte(3).optional(), +}); + +export type LogtoOidcConfig = z.infer; + +// Summary +export const logtoConfigGuards = Object.freeze({ + alterationState: alterationStateGuard, + oidcConfig: logtoOidcConfigGuard, +} as const); + +export type LogtoConfigKey = keyof typeof logtoConfigGuards; + +// `as` is intended since we'd like to keep `logtoConfigGuards` as the SSOT of keys +// eslint-disable-next-line no-restricted-syntax +export const logtoConfigKeys = Object.keys(logtoConfigGuards) as LogtoConfigKey[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e6ae017..343cb6c9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,7 @@ importers: packages/cli: specifiers: + '@logto/schemas': ^1.0.0-beta.10 '@silverhand/eslint-config': 1.0.0 '@silverhand/ts-config': 1.0.0 '@types/decompress': ^4.2.4 @@ -39,12 +40,15 @@ importers: prettier: ^2.7.1 rimraf: ^3.0.2 semver: ^7.3.7 + slonik: ^30.0.0 + slonik-interceptor-preset: ^1.2.10 tar: ^6.1.11 ts-node: ^10.9.1 typescript: ^4.7.4 yargs: ^17.6.0 zod: ^3.18.0 dependencies: + '@logto/schemas': link:../schemas chalk: 4.1.2 find-up: 5.0.0 got: 11.8.3 @@ -52,6 +56,8 @@ importers: inquirer: 8.2.2 ora: 5.4.1 semver: 7.3.7 + slonik: 30.1.2 + slonik-interceptor-preset: 1.2.10 tar: 6.1.11 yargs: 17.6.0 zod: 3.18.0 @@ -1730,6 +1736,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -1760,6 +1767,7 @@ packages: p-waterfall: 2.1.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -1900,6 +1908,7 @@ packages: whatwg-url: 8.7.0 yargs-parser: 20.2.4 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2097,6 +2106,7 @@ packages: npm-registry-fetch: 9.0.0 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2126,6 +2136,7 @@ packages: pify: 5.0.0 read-package-json: 3.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2164,6 +2175,7 @@ packages: npmlog: 4.1.2 tar: 6.1.11 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2262,6 +2274,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -2307,6 +2320,7 @@ packages: '@npmcli/run-script': 3.0.2 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2408,6 +2422,7 @@ packages: slash: 3.0.0 write-json-file: 4.3.0 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -2663,6 +2678,7 @@ packages: treeverse: 2.0.0 walk-up-path: 1.0.0 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2698,6 +2714,8 @@ packages: promise-retry: 2.0.1 semver: 7.3.7 which: 2.0.2 + transitivePeerDependencies: + - bluebird dev: true /@npmcli/installed-package-contents/1.0.7: @@ -2728,6 +2746,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2779,6 +2798,7 @@ packages: node-gyp: 9.0.0 read-package-json-fast: 2.0.3 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -3831,6 +3851,7 @@ packages: stylelint-config-xo-scss: 0.15.0_eqpuutlgonckfyjzwkrpusdvaa transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -3851,6 +3872,7 @@ packages: stylelint-config-xo-scss: 0.15.0_uyk3cwxn3favstz4untq233szu transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -5463,6 +5485,8 @@ packages: ssri: 8.0.1 tar: 6.1.11 unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird dev: true /cacache/16.1.0: @@ -5487,6 +5511,8 @@ packages: ssri: 9.0.1 tar: 6.1.11 unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird dev: true /cache-content-type/1.0.1: @@ -6319,6 +6345,18 @@ packages: ms: 2.1.3 dev: true + /debug/3.2.7_supports-color@5.5.0: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + supports-color: 5.5.0 + dev: true + /debug/4.3.3: resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} engines: {node: '>=6.0'} @@ -10126,6 +10164,7 @@ packages: import-local: 3.1.0 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -10160,6 +10199,7 @@ packages: npm-package-arg: 8.1.5 npm-registry-fetch: 11.0.0 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10173,6 +10213,7 @@ packages: semver: 7.3.7 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10474,6 +10515,7 @@ packages: socks-proxy-agent: 6.1.1 ssri: 9.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10497,6 +10539,7 @@ packages: socks-proxy-agent: 5.0.1 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10521,6 +10564,7 @@ packages: socks-proxy-agent: 6.1.1 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11421,6 +11465,7 @@ packages: tar: 6.1.11 which: 2.0.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11455,7 +11500,7 @@ packages: requiresBuild: true dependencies: chokidar: 3.5.3 - debug: 3.2.7 + debug: 3.2.7_supports-color@5.5.0 ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 @@ -11604,6 +11649,7 @@ packages: minizlib: 2.1.2 npm-package-arg: 8.1.5 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11619,6 +11665,7 @@ packages: npm-package-arg: 9.0.2 proc-log: 2.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11635,6 +11682,7 @@ packages: minizlib: 2.1.2 npm-package-arg: 8.1.5 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -12035,6 +12083,7 @@ packages: ssri: 9.0.1 tar: 6.1.11 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -12736,6 +12785,11 @@ packages: /promise-inflight/1.0.1: resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true dev: true /promise-retry/2.0.1: