mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
added authentication, $schema, fixed media adapter mcp
This commit is contained in:
@@ -64,7 +64,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.5.2-rc.1",
|
"jsonv-ts": "0.6.0",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DB } from "bknd";
|
import type { DB, PrimaryFieldType } from "bknd";
|
||||||
import * as AuthPermissions from "auth/auth-permissions";
|
import * as AuthPermissions from "auth/auth-permissions";
|
||||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||||
@@ -87,6 +87,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
super.setBuilt();
|
super.setBuilt();
|
||||||
|
|
||||||
this._controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
|
this._controller.registerMcp();
|
||||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||||
this.ctx.guard.registerPermissions(AuthPermissions);
|
this.ctx.guard.registerPermissions(AuthPermissions);
|
||||||
}
|
}
|
||||||
@@ -176,6 +177,32 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: PrimaryFieldType, newPassword: string) {
|
||||||
|
const users_entity = this.config.entity_name as "users";
|
||||||
|
const { data: user } = await this.em.repository(users_entity).findId(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
} else if (user.strategy !== "password") {
|
||||||
|
throw new Error("User is not using password strategy");
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePw = (visible: boolean) => {
|
||||||
|
const field = this.em.entity(users_entity).field("strategy_value")!;
|
||||||
|
|
||||||
|
field.config.hidden = !visible;
|
||||||
|
field.config.fillable = visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pw = this.authenticator.strategy("password" as const) as PasswordStrategy;
|
||||||
|
togglePw(true);
|
||||||
|
await this.em.mutator(users_entity).updateOne(user.id, {
|
||||||
|
strategy_value: await pw.hash(newPassword),
|
||||||
|
});
|
||||||
|
togglePw(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||||
if (!this.config.enabled) {
|
if (!this.config.enabled) {
|
||||||
return this.configDefault;
|
return this.configDefault;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SafeUser } from "bknd";
|
import type { DB, SafeUser } from "bknd";
|
||||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||||
import type { AppAuth } from "auth/AppAuth";
|
import type { AppAuth } from "auth/AppAuth";
|
||||||
import * as AuthPermissions from "auth/auth-permissions";
|
import * as AuthPermissions from "auth/auth-permissions";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
transformObject,
|
transformObject,
|
||||||
mcpTool,
|
mcpTool,
|
||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
|
||||||
export type AuthActionResponse = {
|
export type AuthActionResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -200,4 +201,110 @@ export class AuthController extends Controller {
|
|||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override registerMcp(): void {
|
||||||
|
const { mcp } = this.auth.ctx;
|
||||||
|
|
||||||
|
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||||
|
let user: DB["users"] | undefined = undefined;
|
||||||
|
if (params.id) {
|
||||||
|
const { data } = await this.userRepo.findId(params.id);
|
||||||
|
user = data;
|
||||||
|
} else if (params.email) {
|
||||||
|
const { data } = await this.userRepo.findOne({ email: params.email });
|
||||||
|
user = data;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_create",
|
||||||
|
{
|
||||||
|
description: "Create a new user",
|
||||||
|
inputSchema: s.object({
|
||||||
|
email: s.string({ format: "email" }),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
role: s
|
||||||
|
.string({
|
||||||
|
enum: Object.keys(this.auth.config.roles ?? {}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
return c.json(await this.auth.createUser(params));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_token",
|
||||||
|
{
|
||||||
|
description: "Get a user token",
|
||||||
|
inputSchema: s.object({
|
||||||
|
id: s.anyOf([s.string(), s.number()]).optional(),
|
||||||
|
email: s.string({ format: "email" }).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
const user = await getUser(params);
|
||||||
|
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_password_change",
|
||||||
|
{
|
||||||
|
description: "Change a user's password",
|
||||||
|
inputSchema: s.object({
|
||||||
|
id: s.anyOf([s.string(), s.number()]).optional(),
|
||||||
|
email: s.string({ format: "email" }).optional(),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
const user = await getUser(params);
|
||||||
|
if (!(await this.auth.changePassword(user.id, params.password))) {
|
||||||
|
throw new Error("Failed to change password");
|
||||||
|
}
|
||||||
|
return c.json({ changed: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_password_test",
|
||||||
|
{
|
||||||
|
description: "Test a user's password",
|
||||||
|
inputSchema: s.object({
|
||||||
|
email: s.string({ format: "email" }),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
|
const controller = pw.getController(this.auth.authenticator);
|
||||||
|
|
||||||
|
const res = await controller.request(
|
||||||
|
new Request("https://localhost/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: params.email,
|
||||||
|
password: params.password,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ valid: res.ok });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { McpSchema } from "modules/mcp";
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper";
|
import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper";
|
||||||
|
import { getVersion } from "cli/utils/sys";
|
||||||
|
|
||||||
export const mcp: CliCommand = (program) =>
|
export const mcp: CliCommand = (program) =>
|
||||||
program
|
program
|
||||||
@@ -12,15 +13,24 @@ export const mcp: CliCommand = (program) =>
|
|||||||
.description("mcp server")
|
.description("mcp server")
|
||||||
.option("--port <port>", "port to listen on", "3000")
|
.option("--port <port>", "port to listen on", "3000")
|
||||||
.option("--path <path>", "path to listen on", "/mcp")
|
.option("--path <path>", "path to listen on", "/mcp")
|
||||||
|
.option(
|
||||||
|
"--token <token>",
|
||||||
|
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
|
||||||
|
)
|
||||||
|
.option("--log-level <level>", "log level")
|
||||||
.action(action);
|
.action(action);
|
||||||
|
|
||||||
async function action(options: { port: string; path: string }) {
|
async function action(options: {
|
||||||
|
port?: string;
|
||||||
|
path?: string;
|
||||||
|
token?: string;
|
||||||
|
logLevel?: string;
|
||||||
|
}) {
|
||||||
const app = await makeAppFromEnv({
|
const app = await makeAppFromEnv({
|
||||||
server: "node",
|
server: "node",
|
||||||
});
|
});
|
||||||
|
|
||||||
//console.log(info(app.server));
|
const token = options.token || process.env.BEARER_TOKEN;
|
||||||
|
|
||||||
const middlewareServer = getMcpServer(app.server);
|
const middlewareServer = getMcpServer(app.server);
|
||||||
|
|
||||||
const appConfig = app.modules.configs();
|
const appConfig = app.modules.configs();
|
||||||
@@ -33,25 +43,34 @@ async function action(options: { port: string; path: string }) {
|
|||||||
) as s.Node<McpSchema>[];
|
) as s.Node<McpSchema>[];
|
||||||
const tools = [
|
const tools = [
|
||||||
...middlewareServer.tools,
|
...middlewareServer.tools,
|
||||||
...nodes.flatMap((n) => n.schema.getTools(n)),
|
|
||||||
...app.modules.ctx().mcp.tools,
|
...app.modules.ctx().mcp.tools,
|
||||||
|
...nodes.flatMap((n) => n.schema.getTools(n)),
|
||||||
];
|
];
|
||||||
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
||||||
|
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: "bknd",
|
name: "bknd",
|
||||||
version: "0.0.1",
|
version: await getVersion(),
|
||||||
},
|
},
|
||||||
{ app, ctx: () => app.modules.ctx() },
|
{ app, ctx: () => app.modules.ctx() },
|
||||||
tools,
|
tools,
|
||||||
resources,
|
resources,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
server.setAuthentication({
|
||||||
|
type: "bearer",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const hono = new Hono().use(
|
const hono = new Hono().use(
|
||||||
mcpMiddleware({
|
mcpMiddleware({
|
||||||
server,
|
server,
|
||||||
|
sessionsEnabled: true,
|
||||||
debug: {
|
debug: {
|
||||||
|
logLevel: options.logLevel as any,
|
||||||
explainEndpoint: true,
|
explainEndpoint: true,
|
||||||
},
|
},
|
||||||
endpoint: {
|
endpoint: {
|
||||||
|
|||||||
@@ -85,9 +85,6 @@ async function create(app: App, options: any) {
|
|||||||
|
|
||||||
async function update(app: App, options: any) {
|
async function update(app: App, options: any) {
|
||||||
const config = app.module.auth.toJSON(true);
|
const config = app.module.auth.toJSON(true);
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
|
||||||
const users_entity = config.entity_name as "users";
|
|
||||||
const em = app.modules.ctx().em;
|
|
||||||
|
|
||||||
const email = (await $text({
|
const email = (await $text({
|
||||||
message: "Which user? Enter email",
|
message: "Which user? Enter email",
|
||||||
@@ -100,7 +97,10 @@ async function update(app: App, options: any) {
|
|||||||
})) as string;
|
})) as string;
|
||||||
if ($isCancel(email)) process.exit(1);
|
if ($isCancel(email)) process.exit(1);
|
||||||
|
|
||||||
const { data: user } = await em.repository(users_entity).findOne({ email });
|
const { data: user } = await app.modules
|
||||||
|
.ctx()
|
||||||
|
.em.repository(config.entity_name as "users")
|
||||||
|
.findOne({ email });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
$log.error("User not found");
|
$log.error("User not found");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -118,26 +118,10 @@ async function update(app: App, options: any) {
|
|||||||
});
|
});
|
||||||
if ($isCancel(password)) process.exit(1);
|
if ($isCancel(password)) process.exit(1);
|
||||||
|
|
||||||
try {
|
if (await app.module.auth.changePassword(user.id, password)) {
|
||||||
function togglePw(visible: boolean) {
|
|
||||||
const field = em.entity(users_entity).field("strategy_value")!;
|
|
||||||
|
|
||||||
field.config.hidden = !visible;
|
|
||||||
field.config.fillable = visible;
|
|
||||||
}
|
|
||||||
togglePw(true);
|
|
||||||
await app.modules
|
|
||||||
.ctx()
|
|
||||||
.em.mutator(users_entity)
|
|
||||||
.updateOne(user.id, {
|
|
||||||
strategy_value: await strategy.hash(password as string),
|
|
||||||
});
|
|
||||||
togglePw(false);
|
|
||||||
|
|
||||||
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
||||||
} catch (e) {
|
} else {
|
||||||
$log.error("Error updating user");
|
$log.error("Error updating user");
|
||||||
$console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MediaAdapters } from "media/media-registry";
|
import { MediaAdapters } from "media/media-registry";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { s, objectTransform } from "bknd/utils";
|
import { s, objectTransform } from "bknd/utils";
|
||||||
import { $object, $record } from "modules/mcp";
|
import { $object, $record, $schema } from "modules/mcp";
|
||||||
|
|
||||||
export const ADAPTERS = {
|
export const ADAPTERS = {
|
||||||
...MediaAdapters,
|
...MediaAdapters,
|
||||||
@@ -39,7 +39,10 @@ export function buildMediaSchema() {
|
|||||||
},
|
},
|
||||||
{ default: {} },
|
{ default: {} },
|
||||||
),
|
),
|
||||||
adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(),
|
adapter: $schema(
|
||||||
|
"config_media_adapter",
|
||||||
|
s.anyOf(Object.values(adapterSchemaObject)),
|
||||||
|
).optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: {},
|
default: {},
|
||||||
|
|||||||
72
app/src/modules/mcp/$schema.ts
Normal file
72
app/src/modules/mcp/$schema.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Tool, getPath, s } from "bknd/utils";
|
||||||
|
import {
|
||||||
|
McpSchemaHelper,
|
||||||
|
mcpSchemaSymbol,
|
||||||
|
type AppToolHandlerCtx,
|
||||||
|
type SchemaWithMcpOptions,
|
||||||
|
} from "./McpSchemaHelper";
|
||||||
|
|
||||||
|
export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {}
|
||||||
|
|
||||||
|
export const $schema = <
|
||||||
|
const S extends s.Schema,
|
||||||
|
const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
schema: S,
|
||||||
|
options?: O,
|
||||||
|
): S => {
|
||||||
|
const mcp = new McpSchemaHelper(schema, name, options || {});
|
||||||
|
|
||||||
|
const toolGet = (node: s.Node<S>) => {
|
||||||
|
return new Tool(
|
||||||
|
[mcp.name, "get"].join("_"),
|
||||||
|
{
|
||||||
|
...mcp.getToolOptions("get"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
secrets: s
|
||||||
|
.boolean({
|
||||||
|
default: false,
|
||||||
|
description: "Include secrets in the response config",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
|
const configs = ctx.context.app.toJSON(params.secrets);
|
||||||
|
const value = getPath(configs, node.instancePath);
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
secrets: params.secrets ?? false,
|
||||||
|
value: value ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolUpdate = (node: s.Node<S>) => {
|
||||||
|
return new Tool(
|
||||||
|
[mcp.name, "update"].join("_"),
|
||||||
|
{
|
||||||
|
...mcp.getToolOptions("update"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
full: s.boolean({ default: false }).optional(),
|
||||||
|
value: schema as any,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
|
return ctx.json(params);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTools = (node: s.Node<S>) => {
|
||||||
|
const { tools = [] } = mcp.options;
|
||||||
|
return [toolGet(node), toolUpdate(node), ...tools];
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.assign(schema, {
|
||||||
|
[mcpSchemaSymbol]: mcp,
|
||||||
|
getTools,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./$object";
|
export * from "./$object";
|
||||||
export * from "./$record";
|
export * from "./$record";
|
||||||
|
export * from "./$schema";
|
||||||
export * from "./McpSchemaHelper";
|
export * from "./McpSchemaHelper";
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -35,7 +35,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.5.2-rc.1",
|
"jsonv-ts": "0.6.0",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -2511,7 +2511,7 @@
|
|||||||
|
|
||||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||||
|
|
||||||
"jsonv-ts": ["jsonv-ts@0.5.2-rc.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-mGD7peeZcrr/GeeIWxYDZTSa2/LHSeP0cWIELR63WI9p+PWJRUuDOQ4pHcESbG/syyEBvuus4Nbljnlrxwi2bQ=="],
|
"jsonv-ts": ["jsonv-ts@0.6.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-U9u2Gtv5NyYwMvSHPcOwd9Qgg4cUFPepI5vTkwU7Ib01ipEqaRrM/AM+REEbYqiI6LX+7YF+HcMbgCIHkUMhSQ=="],
|
||||||
|
|
||||||
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user