@logto/cli 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
2
2
  import type { CommandModule } from 'yargs';
3
3
  import type { AlterationFile } from './type.js';
4
4
  export declare const getLatestAlterationTimestamp: () => Promise<number>;
5
- export declare const getAvailableAlterations: (pool: CommonQueryMethods, compareMode?: 'gt' | 'lte') => Promise<AlterationFile[]>;
5
+ export declare const getAvailableAlterations: (pool: CommonQueryMethods, compareMode?: "gt" | "lte") => Promise<AlterationFile[]>;
6
6
  declare const alteration: CommandModule<unknown, {
7
7
  action: string;
8
8
  target?: string;
@@ -0,0 +1,4 @@
1
+ import type { CommandModule } from 'yargs';
2
+ import { type ProxyCommandArgs } from './types.js';
3
+ declare const proxy: CommandModule<unknown, ProxyCommandArgs>;
4
+ export default proxy;
@@ -0,0 +1,80 @@
1
+ import http from 'node:http';
2
+ import { isValidUrl } from '@logto/core-kit';
3
+ import { conditional } from '@silverhand/essentials';
4
+ import chalk from 'chalk';
5
+ import { consoleLog } from '../../utils.js';
6
+ import { checkExperienceInput, createLogtoResponseHandler, createProxy, createStaticFileProxy, isLogtoRequestPath, } from './utils.js';
7
+ const proxy = {
8
+ command: ['proxy'],
9
+ describe: 'Command for Logto proxy',
10
+ builder: (yargs) => yargs
11
+ .options({
12
+ 'experience-uri': {
13
+ alias: ['x'],
14
+ describe: 'The URI of your custom sign-in experience page.',
15
+ type: 'string',
16
+ },
17
+ 'experience-path': {
18
+ alias: ['xp'],
19
+ describe: 'The local folder path of your custom sign-in experience assets.',
20
+ type: 'string',
21
+ },
22
+ endpoint: {
23
+ alias: 'ep',
24
+ describe: 'Logto endpoint URI, which can be found in Logto Console. E.g.: https://<tenant-id>.logto.app/',
25
+ type: 'string',
26
+ },
27
+ port: {
28
+ alias: 'p',
29
+ describe: 'The port number where the proxy server will be running on. Defaults to 9000.',
30
+ type: 'number',
31
+ default: 9000,
32
+ },
33
+ verbose: {
34
+ alias: 'v',
35
+ describe: 'Show verbose output.',
36
+ type: 'boolean',
37
+ default: false,
38
+ },
39
+ })
40
+ .global('e'),
41
+ handler: async ({ 'experience-uri': url, 'experience-path': path, endpoint, port, verbose }) => {
42
+ checkExperienceInput(url, path);
43
+ if (!endpoint || !isValidUrl(endpoint)) {
44
+ consoleLog.fatal('A valid Logto endpoint URI must be provided.');
45
+ }
46
+ const logtoEndpointUrl = new URL(endpoint);
47
+ const proxyUrl = new URL(`http://localhost:${port}`);
48
+ const proxyLogtoRequest = createProxy(logtoEndpointUrl.href, async (proxyResponse, request, response) => createLogtoResponseHandler({
49
+ proxyResponse,
50
+ request,
51
+ response,
52
+ logtoEndpointUrl,
53
+ proxyUrl,
54
+ verbose,
55
+ }));
56
+ const proxyExperienceServerRequest = conditional(url && createProxy(url));
57
+ const proxyExperienceStaticFileRequest = conditional(path && createStaticFileProxy(path));
58
+ const server = http.createServer((request, response) => {
59
+ if (verbose) {
60
+ consoleLog.info(`Incoming request: ${chalk.blue(request.method, request.url)}`);
61
+ }
62
+ // Proxy the requests to Logto endpoint
63
+ if (isLogtoRequestPath(request.url)) {
64
+ void proxyLogtoRequest(request, response);
65
+ return;
66
+ }
67
+ if (proxyExperienceServerRequest) {
68
+ void proxyExperienceServerRequest(request, response);
69
+ return;
70
+ }
71
+ if (proxyExperienceStaticFileRequest) {
72
+ void proxyExperienceStaticFileRequest(request, response);
73
+ }
74
+ });
75
+ server.listen(port, () => {
76
+ consoleLog.info(`Proxy server is running on ${chalk.blue(proxyUrl.href)}`);
77
+ });
78
+ },
79
+ };
80
+ export default proxy;
@@ -0,0 +1,16 @@
1
+ import type * as http from 'node:http';
2
+ export type ProxyCommandArgs = {
3
+ 'experience-uri'?: string;
4
+ 'experience-path'?: string;
5
+ endpoint?: string;
6
+ port: number;
7
+ verbose: boolean;
8
+ };
9
+ export type ProxyResponseHandler = {
10
+ proxyResponse: http.IncomingMessage;
11
+ request: http.IncomingMessage;
12
+ response: http.ServerResponse;
13
+ logtoEndpointUrl: URL;
14
+ proxyUrl: URL;
15
+ verbose: boolean;
16
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import type http from 'node:http';
2
+ import { type OnProxyEvent } from 'http-proxy-middleware/dist/types.js';
3
+ import { type ProxyResponseHandler } from './types.js';
4
+ export declare const createProxy: (targetUrl: string, onProxyResponse?: OnProxyEvent["proxyRes"]) => import("http-proxy-middleware").RequestHandler<http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, (err?: any) => void>;
5
+ export declare const createStaticFileProxy: (staticPath: string) => (request: http.IncomingMessage, response: http.ServerResponse) => Promise<void>;
6
+ /**
7
+ * Intercept the response from Logto endpoint and replace Logto endpoint URLs in the response with the
8
+ * proxy URL. The string replace happens in the following cases:
9
+ * - The response is a redirect response, and the `location` property in response header may contain Logto
10
+ * endpoint URI.
11
+ * - The response body is JSON, which consists of properties such as `**_endpoint` and `redirectTo`. These
12
+ * properties may contain Logto endpoint URI.
13
+ * - The response is HTML content that contains a form. The form action URL may contain Logto endpoint URI.
14
+ *
15
+ * Note: the `issuer` and `jwks_uri` properties in the `/oidc/.well-known` response should not be replaced,
16
+ * even they also contain the Logto endpoint URI.
17
+ */
18
+ export declare const createLogtoResponseHandler: ({ proxyResponse, request, response, logtoEndpointUrl, proxyUrl, verbose, }: ProxyResponseHandler) => Promise<void>;
19
+ export declare const checkExperienceInput: (url?: string, staticPath?: string) => void;
20
+ /**
21
+ * Check if the request path is a Logto request path.
22
+ * @example isLogtoRequestPath('/oidc/.well-known/openid-configuration') // true
23
+ * @example isLogtoRequestPath('/oidc/auth') // true
24
+ * @example isLogtoRequestPath('/api/interaction/submit') // true
25
+ * @example isLogtoRequestPath('/consent') // true
26
+ */
27
+ export declare const isLogtoRequestPath: (requestPath?: string) => boolean;
@@ -0,0 +1,123 @@
1
+ import { existsSync } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { isValidUrl } from '@logto/core-kit';
5
+ import { conditional, trySafe } from '@silverhand/essentials';
6
+ import chalk from 'chalk';
7
+ import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
8
+ import mime from 'mime';
9
+ import { consoleLog } from '../../utils.js';
10
+ export const createProxy = (targetUrl, onProxyResponse) => {
11
+ const hasResponseHandler = Boolean(onProxyResponse);
12
+ return createProxyMiddleware({
13
+ target: targetUrl,
14
+ changeOrigin: true,
15
+ selfHandleResponse: hasResponseHandler,
16
+ ...conditional(hasResponseHandler && {
17
+ on: {
18
+ proxyRes: onProxyResponse,
19
+ error: (error) => {
20
+ consoleLog.error(chalk.red(error));
21
+ },
22
+ },
23
+ }),
24
+ });
25
+ };
26
+ const index = 'index.html';
27
+ const indexContentType = 'text/html; charset=utf-8';
28
+ const noCache = 'no-cache, no-store, must-revalidate';
29
+ const maxAgeSevenDays = 'max-age=604_800_000';
30
+ export const createStaticFileProxy = (staticPath) => async (request, response) => {
31
+ if (!request.url) {
32
+ response.writeHead(400).end();
33
+ return;
34
+ }
35
+ if (request.method === 'HEAD' || request.method === 'GET') {
36
+ const fallBackToIndex = !isFileAssetPath(request.url);
37
+ const requestPath = path.join(staticPath, fallBackToIndex ? index : request.url);
38
+ try {
39
+ const content = await fs.readFile(requestPath, 'utf8');
40
+ response.setHeader('cache-control', fallBackToIndex ? noCache : maxAgeSevenDays);
41
+ response.setHeader('content-type', getMimeType(request.url));
42
+ response.writeHead(200);
43
+ response.end(content);
44
+ }
45
+ catch (error) {
46
+ consoleLog.error(chalk.red(error));
47
+ response.setHeader('content-type', getMimeType(request.url));
48
+ response.writeHead(existsSync(request.url) ? 500 : 404);
49
+ response.end();
50
+ }
51
+ }
52
+ };
53
+ /**
54
+ * Intercept the response from Logto endpoint and replace Logto endpoint URLs in the response with the
55
+ * proxy URL. The string replace happens in the following cases:
56
+ * - The response is a redirect response, and the `location` property in response header may contain Logto
57
+ * endpoint URI.
58
+ * - The response body is JSON, which consists of properties such as `**_endpoint` and `redirectTo`. These
59
+ * properties may contain Logto endpoint URI.
60
+ * - The response is HTML content that contains a form. The form action URL may contain Logto endpoint URI.
61
+ *
62
+ * Note: the `issuer` and `jwks_uri` properties in the `/oidc/.well-known` response should not be replaced,
63
+ * even they also contain the Logto endpoint URI.
64
+ */
65
+ export const createLogtoResponseHandler = async ({ proxyResponse, request, response, logtoEndpointUrl, proxyUrl, verbose, }) => {
66
+ const { location } = proxyResponse.headers;
67
+ if (location) {
68
+ // eslint-disable-next-line @silverhand/fp/no-mutation
69
+ proxyResponse.headers.location = location.replace(logtoEndpointUrl.href, proxyUrl.href);
70
+ }
71
+ void responseInterceptor(async (responseBuffer, proxyResponse) => {
72
+ const responseBody = responseBuffer.toString();
73
+ if (verbose) {
74
+ consoleLog.info(`Response received: ${chalk.green(responseBody)}`);
75
+ }
76
+ if (proxyResponse.headers['content-type']?.includes('text/html')) {
77
+ return responseBody.replace(`action="${logtoEndpointUrl.href}`, `action="${proxyUrl.href}`);
78
+ }
79
+ if (proxyResponse.headers['content-type']?.includes('application/json')) {
80
+ const jsonData = trySafe(() => JSON.parse(responseBody));
81
+ if (jsonData && typeof jsonData === 'object') {
82
+ const updatedEntries = Object.entries(jsonData).map(([key, value]) => {
83
+ if ((key === 'redirectTo' || key.endsWith('_endpoint')) && typeof value === 'string') {
84
+ return [key, value.replace(logtoEndpointUrl.href, proxyUrl.href)];
85
+ }
86
+ return [key, value];
87
+ });
88
+ return JSON.stringify(Object.fromEntries(updatedEntries));
89
+ }
90
+ }
91
+ return responseBody;
92
+ })(proxyResponse, request, response);
93
+ };
94
+ export const checkExperienceInput = (url, staticPath) => {
95
+ if (staticPath && url) {
96
+ consoleLog.fatal('Only one of the experience URI or path can be provided.');
97
+ }
98
+ if (!staticPath && !url) {
99
+ consoleLog.fatal('Either a sign-in experience URI or local path must be provided.');
100
+ }
101
+ if (url && !isValidUrl(url)) {
102
+ consoleLog.fatal('A valid sign-in experience URI must be provided. E.g.: http://localhost:4000');
103
+ }
104
+ if (staticPath && !existsSync(path.join(staticPath, index))) {
105
+ consoleLog.fatal('The provided path does not contain a valid index.html file.');
106
+ }
107
+ };
108
+ /**
109
+ * Check if the request path is a Logto request path.
110
+ * @example isLogtoRequestPath('/oidc/.well-known/openid-configuration') // true
111
+ * @example isLogtoRequestPath('/oidc/auth') // true
112
+ * @example isLogtoRequestPath('/api/interaction/submit') // true
113
+ * @example isLogtoRequestPath('/consent') // true
114
+ */
115
+ export const isLogtoRequestPath = (requestPath) => ['/oidc/', '/api/'].some((path) => requestPath?.startsWith(path)) || requestPath === '/consent';
116
+ const isFileAssetPath = (url) => url.split('/').at(-1)?.includes('.');
117
+ const getMimeType = (requestPath) => {
118
+ const fallBackToIndex = !isFileAssetPath(requestPath);
119
+ if (fallBackToIndex) {
120
+ return indexContentType;
121
+ }
122
+ return mime.getType(requestPath) ?? 'application/octet-stream';
123
+ };
@@ -3,9 +3,9 @@ import { ConnectorType } from '@logto/connector-kit';
3
3
  import type { BaseRoutes, Router } from '@withtyped/server';
4
4
  import type { ConnectorFactory } from './types.js';
5
5
  export declare function validateConnectorModule(connector: Partial<BaseConnector<ConnectorType>>): asserts connector is BaseConnector<ConnectorType>;
6
- export declare const readUrl: (url: string, baseUrl: string, type: 'text' | 'image') => Promise<string>;
7
- export declare const parseMetadata: (metadata: AllConnector['metadata'], packagePath: string) => Promise<AllConnector['metadata']>;
8
- export declare const buildRawConnector: <T extends Router<any, any, BaseRoutes, string>, U extends AllConnector = AllConnector>(connectorFactory: ConnectorFactory<T, U>, getConnectorConfig?: GetConnectorConfig, getCloudServiceClient?: GetCloudServiceClient<T> | undefined) => Promise<{
6
+ export declare const readUrl: (url: string, baseUrl: string, type: "text" | "image") => Promise<string>;
7
+ export declare const parseMetadata: (metadata: AllConnector["metadata"], packagePath: string) => Promise<AllConnector["metadata"]>;
8
+ export declare const buildRawConnector: <T extends Router<any, any, BaseRoutes, string>, U extends AllConnector = AllConnector>(connectorFactory: ConnectorFactory<T, U>, getConnectorConfig?: GetConnectorConfig, getCloudServiceClient?: GetCloudServiceClient<T>) => Promise<{
9
9
  rawConnector: U;
10
10
  rawMetadata: ConnectorMetadata;
11
11
  }>;
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers';
5
5
  import connector from './commands/connector/index.js';
6
6
  import database from './commands/database/index.js';
7
7
  import install from './commands/install/index.js';
8
+ import proxy from './commands/proxy/index.js';
8
9
  import translate from './commands/translate/index.js';
9
10
  import { packageJson } from './package-json.js';
10
11
  import { cliConfig, ConfigKey, consoleLog } from './utils.js';
@@ -44,6 +45,7 @@ void yargs(hideBin(process.argv))
44
45
  .command(database)
45
46
  .command(connector)
46
47
  .command(translate)
48
+ .command(proxy)
47
49
  .demandCommand(1)
48
50
  .showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`)
49
51
  .strict()
@@ -53,7 +53,9 @@ export declare const packageJson: {
53
53
  dotenv: string;
54
54
  got: string;
55
55
  hpagent: string;
56
+ "http-proxy-middleware": string;
56
57
  inquirer: string;
58
+ mime: string;
57
59
  nanoid: string;
58
60
  ora: string;
59
61
  "p-limit": string;
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -45,30 +45,32 @@ export const packageJson = {
45
45
  "@logto/connector-kit": "workspace:^4.0.0",
46
46
  "@logto/core-kit": "workspace:^2.5.0",
47
47
  "@logto/language-kit": "workspace:^1.1.0",
48
- "@logto/phrases": "workspace:^1.12.0",
48
+ "@logto/phrases": "workspace:^1.13.0",
49
49
  "@logto/phrases-experience": "workspace:^1.7.0",
50
- "@logto/schemas": "workspace:1.18.0",
50
+ "@logto/schemas": "workspace:1.19.0",
51
51
  "@logto/shared": "workspace:^3.1.1",
52
52
  "@silverhand/essentials": "^2.9.1",
53
53
  "@silverhand/slonik": "31.0.0-beta.2",
54
- "chalk": "^5.0.0",
54
+ "chalk": "^5.3.0",
55
55
  "decamelize": "^6.0.0",
56
- "dotenv": "^16.0.0",
56
+ "dotenv": "^16.4.5",
57
57
  "got": "^14.0.0",
58
58
  "hpagent": "^1.2.0",
59
+ "http-proxy-middleware": "^3.0.0",
59
60
  "inquirer": "^9.0.0",
61
+ "mime": "^4.0.4",
60
62
  "nanoid": "^5.0.1",
61
63
  "ora": "^8.0.1",
62
- "p-limit": "^5.0.0",
64
+ "p-limit": "^6.0.0",
63
65
  "p-queue": "^8.0.0",
64
66
  "p-retry": "^6.0.0",
65
67
  "pg-protocol": "^1.6.0",
66
68
  "roarr": "^7.11.0",
67
69
  "semver": "^7.3.8",
68
70
  "tar": "^7.0.0",
69
- "typescript": "^5.3.3",
71
+ "typescript": "^5.5.3",
70
72
  "yargs": "^17.6.0",
71
- "zod": "^3.22.4"
73
+ "zod": "^3.23.8"
72
74
  },
73
75
  "devDependencies": {
74
76
  "@silverhand/eslint-config": "6.0.1",
@@ -79,13 +81,13 @@ export const packageJson = {
79
81
  "@types/sinon": "^17.0.0",
80
82
  "@types/tar": "^6.1.12",
81
83
  "@types/yargs": "^17.0.13",
82
- "@vitest/coverage-v8": "^1.4.0",
84
+ "@vitest/coverage-v8": "^2.0.0",
83
85
  "@withtyped/server": "^0.13.6",
84
86
  "eslint": "^8.56.0",
85
87
  "lint-staged": "^15.0.0",
86
88
  "prettier": "^3.0.0",
87
89
  "sinon": "^18.0.0",
88
- "vitest": "^1.4.0"
90
+ "vitest": "^2.0.0"
89
91
  },
90
92
  "eslintConfig": {
91
93
  "extends": "@silverhand",
@@ -3,4 +3,4 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
3
3
  import type { z } from 'zod';
4
4
  export declare const doesConfigsTableExist: (pool: CommonQueryMethods) => Promise<boolean>;
5
5
  export declare const getRowsByKeys: (pool: CommonQueryMethods, tenantId: string, keys: LogtoConfigKey[]) => Promise<import("@silverhand/slonik").QueryResult<LogtoConfig>>;
6
- export declare const updateValueByKey: <T extends LogtoConfigKey>(pool: CommonQueryMethods, tenantId: string, key: T, value: z.TypeOf<import("@logto/schemas").LogtoConfigGuard[T]>) => Promise<import("@silverhand/slonik").QueryResult<import("@silverhand/slonik").QueryResultRow>>;
6
+ export declare const updateValueByKey: <T extends LogtoConfigKey>(pool: CommonQueryMethods, tenantId: string, key: T, value: z.infer<(typeof logtoConfigGuards)[T]>) => Promise<import("@silverhand/slonik").QueryResult<import("@silverhand/slonik").QueryResultRow>>;
@@ -6,4 +6,4 @@ export declare const doesSystemsTableExist: (pool: CommonQueryMethods) => Promis
6
6
  export declare const getCurrentDatabaseAlterationTimestamp: (pool: CommonQueryMethods) => Promise<number>;
7
7
  export declare const updateDatabaseTimestamp: (connection: DatabaseTransactionConnection, timestamp: number) => Promise<void>;
8
8
  export declare const getRowByKey: (pool: CommonQueryMethods, key: SystemKey) => Promise<System | null>;
9
- export declare const updateValueByKey: <T extends SystemKey>(pool: CommonQueryMethods, key: T, value: z.TypeOf<import("@logto/schemas").SystemGuard[T]>) => Promise<import("@silverhand/slonik").QueryResult<import("@silverhand/slonik").QueryResultRow>>;
9
+ export declare const updateValueByKey: <T extends SystemKey>(pool: CommonQueryMethods, key: T, value: z.infer<(typeof systemGuards)[T]>) => Promise<import("@silverhand/slonik").QueryResult<import("@silverhand/slonik").QueryResultRow>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -31,30 +31,32 @@
31
31
  "@logto/connector-kit": "^4.0.0",
32
32
  "@logto/core-kit": "^2.5.0",
33
33
  "@logto/language-kit": "^1.1.0",
34
- "@logto/phrases": "^1.12.0",
34
+ "@logto/phrases": "^1.13.0",
35
35
  "@logto/phrases-experience": "^1.7.0",
36
- "@logto/schemas": "1.18.0",
36
+ "@logto/schemas": "1.19.0",
37
37
  "@logto/shared": "^3.1.1",
38
38
  "@silverhand/essentials": "^2.9.1",
39
39
  "@silverhand/slonik": "31.0.0-beta.2",
40
- "chalk": "^5.0.0",
40
+ "chalk": "^5.3.0",
41
41
  "decamelize": "^6.0.0",
42
- "dotenv": "^16.0.0",
42
+ "dotenv": "^16.4.5",
43
43
  "got": "^14.0.0",
44
44
  "hpagent": "^1.2.0",
45
+ "http-proxy-middleware": "^3.0.0",
45
46
  "inquirer": "^9.0.0",
47
+ "mime": "^4.0.4",
46
48
  "nanoid": "^5.0.1",
47
49
  "ora": "^8.0.1",
48
- "p-limit": "^5.0.0",
50
+ "p-limit": "^6.0.0",
49
51
  "p-queue": "^8.0.0",
50
52
  "p-retry": "^6.0.0",
51
53
  "pg-protocol": "^1.6.0",
52
54
  "roarr": "^7.11.0",
53
55
  "semver": "^7.3.8",
54
56
  "tar": "^7.0.0",
55
- "typescript": "^5.3.3",
57
+ "typescript": "^5.5.3",
56
58
  "yargs": "^17.6.0",
57
- "zod": "^3.22.4"
59
+ "zod": "^3.23.8"
58
60
  },
59
61
  "devDependencies": {
60
62
  "@silverhand/eslint-config": "6.0.1",
@@ -65,13 +67,13 @@
65
67
  "@types/sinon": "^17.0.0",
66
68
  "@types/tar": "^6.1.12",
67
69
  "@types/yargs": "^17.0.13",
68
- "@vitest/coverage-v8": "^1.4.0",
70
+ "@vitest/coverage-v8": "^2.0.0",
69
71
  "@withtyped/server": "^0.13.6",
70
72
  "eslint": "^8.56.0",
71
73
  "lint-staged": "^15.0.0",
72
74
  "prettier": "^3.0.0",
73
75
  "sinon": "^18.0.0",
74
- "vitest": "^1.4.0"
76
+ "vitest": "^2.0.0"
75
77
  },
76
78
  "eslintConfig": {
77
79
  "extends": "@silverhand",