@openinc/parse-server-opendash 4.0.32 → 4.0.34
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.
- package/dist/functions/openinc-rmq-create-tenant.d.ts +28 -0
- package/dist/functions/openinc-rmq-create-tenant.js +105 -0
- package/dist/functions/openinc-rmq-reset-password.d.ts +2 -0
- package/dist/functions/openinc-rmq-reset-password.js +65 -0
- package/dist/hooks/Permission.js +1 -1
- package/dist/hooks/Tenant.js +8 -1
- package/dist/hooks/_ChangeLog.js +20 -19
- package/package.json +5 -5
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface CreateRabbitMQVHostOptions {
|
|
2
|
+
description?: string;
|
|
3
|
+
tags?: string[];
|
|
4
|
+
}
|
|
5
|
+
export interface RMQConfig {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
authHeader: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getRMQConfig(): RMQConfig;
|
|
10
|
+
export declare function createRabbitMQVHost(vhost: string, options?: CreateRabbitMQVHostOptions): Promise<void>;
|
|
11
|
+
export declare function createRabbitMQUser(username: string, password: string, tags?: string[]): Promise<void>;
|
|
12
|
+
export declare function setRabbitMQPermissions(vhost: string, username: string, permissions: {
|
|
13
|
+
configure: string;
|
|
14
|
+
write: string;
|
|
15
|
+
read: string;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export declare function setRabbitMQTopicPermissions(vhost: string, username: string, exchange: string, permissions: {
|
|
18
|
+
write: string;
|
|
19
|
+
read: string;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
export interface CreateRabbitMQTenantResult {
|
|
22
|
+
vhost: string;
|
|
23
|
+
username: string;
|
|
24
|
+
password: string;
|
|
25
|
+
exchange: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function createRabbitMQTenant(tenantid: string, clientname?: string): Promise<CreateRabbitMQTenantResult>;
|
|
28
|
+
export declare function init(name: string): Promise<void>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getRMQConfig = getRMQConfig;
|
|
7
|
+
exports.createRabbitMQVHost = createRabbitMQVHost;
|
|
8
|
+
exports.createRabbitMQUser = createRabbitMQUser;
|
|
9
|
+
exports.setRabbitMQPermissions = setRabbitMQPermissions;
|
|
10
|
+
exports.setRabbitMQTopicPermissions = setRabbitMQTopicPermissions;
|
|
11
|
+
exports.createRabbitMQTenant = createRabbitMQTenant;
|
|
12
|
+
exports.init = init;
|
|
13
|
+
const node_1 = __importDefault(require("parse/node"));
|
|
14
|
+
const createRandomPassword_js_1 = require("../helper/createRandomPassword.js");
|
|
15
|
+
function getRMQConfig() {
|
|
16
|
+
const protocol = process.env.OPENINC_PARSE_CLOUD_RMQ_PROTOCOL || "http";
|
|
17
|
+
const host = process.env.OPENINC_PARSE_CLOUD_RMQ_HOST;
|
|
18
|
+
const port = process.env.OPENINC_PARSE_CLOUD_RMQ_MANAGEMENT_PORT || "15672";
|
|
19
|
+
const username = process.env.OPENINC_PARSE_CLOUD_RMQ_USERNAME;
|
|
20
|
+
const password = process.env.OPENINC_PARSE_CLOUD_RMQ_PASSWORD;
|
|
21
|
+
if (!host) {
|
|
22
|
+
throw new Error("OPENINC_PARSE_CLOUD_RMQ_HOST is not set");
|
|
23
|
+
}
|
|
24
|
+
if (!username || !password) {
|
|
25
|
+
throw new Error("OPENINC_PARSE_CLOUD_RMQ_USERNAME and OPENINC_PARSE_CLOUD_RMQ_PASSWORD must be set");
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
baseUrl: `${protocol}://${host}:${port}`,
|
|
29
|
+
authHeader: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function rmqRequest(path, body, errorContext) {
|
|
33
|
+
const { baseUrl, authHeader } = getRMQConfig();
|
|
34
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: authHeader,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const text = await response.text().catch(() => "");
|
|
44
|
+
throw new Error(`${errorContext} (${response.status} ${response.statusText}): ${text}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function createRabbitMQVHost(vhost, options = {}) {
|
|
48
|
+
if (!vhost || vhost.trim().length === 0) {
|
|
49
|
+
throw new Error("vhost name must not be empty");
|
|
50
|
+
}
|
|
51
|
+
const body = {};
|
|
52
|
+
if (options.description)
|
|
53
|
+
body.description = options.description;
|
|
54
|
+
if (options.tags && options.tags.length > 0)
|
|
55
|
+
body.tags = options.tags.join(",");
|
|
56
|
+
await rmqRequest(`/api/vhosts/${encodeURIComponent(vhost)}`, body, `Failed to create RabbitMQ vhost "${vhost}"`);
|
|
57
|
+
}
|
|
58
|
+
async function createRabbitMQUser(username, password, tags = []) {
|
|
59
|
+
if (!username || username.trim().length === 0) {
|
|
60
|
+
throw new Error("username must not be empty");
|
|
61
|
+
}
|
|
62
|
+
await rmqRequest(`/api/users/${encodeURIComponent(username)}`, { password, tags: tags.join(",") }, `Failed to create RabbitMQ user "${username}"`);
|
|
63
|
+
}
|
|
64
|
+
async function setRabbitMQPermissions(vhost, username, permissions) {
|
|
65
|
+
await rmqRequest(`/api/permissions/${encodeURIComponent(vhost)}/${encodeURIComponent(username)}`, permissions, `Failed to set permissions for "${username}" on vhost "${vhost}"`);
|
|
66
|
+
}
|
|
67
|
+
async function setRabbitMQTopicPermissions(vhost, username, exchange, permissions) {
|
|
68
|
+
await rmqRequest(`/api/topic-permissions/${encodeURIComponent(vhost)}/${encodeURIComponent(username)}`, { exchange, ...permissions }, `Failed to set topic permissions for "${username}" on exchange "${exchange}" in vhost "${vhost}"`);
|
|
69
|
+
}
|
|
70
|
+
async function createRabbitMQTenant(tenantid, clientname) {
|
|
71
|
+
const vhost = clientname ? `${tenantid}_${clientname}` : tenantid;
|
|
72
|
+
const rmqUsername = tenantid;
|
|
73
|
+
const rmqPassword = await (0, createRandomPassword_js_1.createRandomPassword)();
|
|
74
|
+
const exchange = process.env.OPENINC_PARSE_CLOUD_RMQ_TOPIC_EXCHANGE || "amq.topic";
|
|
75
|
+
await createRabbitMQVHost(vhost, {
|
|
76
|
+
description: clientname
|
|
77
|
+
? `vHost for tenant ${tenantid} / client ${clientname}`
|
|
78
|
+
: `vHost for tenant ${tenantid}`,
|
|
79
|
+
});
|
|
80
|
+
await createRabbitMQUser(rmqUsername, rmqPassword);
|
|
81
|
+
await setRabbitMQPermissions(vhost, rmqUsername, {
|
|
82
|
+
configure: ".*",
|
|
83
|
+
write: ".*",
|
|
84
|
+
read: ".*",
|
|
85
|
+
});
|
|
86
|
+
await setRabbitMQTopicPermissions(vhost, rmqUsername, exchange, {
|
|
87
|
+
write: ".*",
|
|
88
|
+
read: ".*",
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
vhost,
|
|
92
|
+
username: rmqUsername,
|
|
93
|
+
password: rmqPassword,
|
|
94
|
+
exchange,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function init(name) {
|
|
98
|
+
node_1.default.Cloud.define(name, async function (request) {
|
|
99
|
+
const { tenantid, clientname } = request.params;
|
|
100
|
+
return await createRabbitMQTenant(tenantid, clientname);
|
|
101
|
+
}, {
|
|
102
|
+
requireMaster: true,
|
|
103
|
+
fields: ["tenantid", "clientname"],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resetRabbitMQUserPassword = resetRabbitMQUserPassword;
|
|
7
|
+
exports.init = init;
|
|
8
|
+
const node_1 = __importDefault(require("parse/node"));
|
|
9
|
+
const createRandomPassword_js_1 = require("../helper/createRandomPassword.js");
|
|
10
|
+
const openinc_rmq_create_tenant_js_1 = require("./openinc-rmq-create-tenant.js");
|
|
11
|
+
async function resetRabbitMQUserPassword(username, newPassword) {
|
|
12
|
+
if (!username || username.trim().length === 0) {
|
|
13
|
+
throw new Error("username must not be empty");
|
|
14
|
+
}
|
|
15
|
+
const { baseUrl, authHeader } = (0, openinc_rmq_create_tenant_js_1.getRMQConfig)();
|
|
16
|
+
const userUrl = `${baseUrl}/api/users/${encodeURIComponent(username)}`;
|
|
17
|
+
const getResponse = await fetch(userUrl, {
|
|
18
|
+
method: "GET",
|
|
19
|
+
headers: { Authorization: authHeader },
|
|
20
|
+
});
|
|
21
|
+
if (!getResponse.ok) {
|
|
22
|
+
const text = await getResponse.text().catch(() => "");
|
|
23
|
+
throw new Error(`Failed to load RabbitMQ user "${username}" (${getResponse.status} ${getResponse.statusText}): ${text}`);
|
|
24
|
+
}
|
|
25
|
+
const existing = (await getResponse.json());
|
|
26
|
+
const password = newPassword || (await (0, createRandomPassword_js_1.createRandomPassword)());
|
|
27
|
+
const putResponse = await fetch(userUrl, {
|
|
28
|
+
method: "PUT",
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: authHeader,
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({ password, tags: existing.tags || "" }),
|
|
34
|
+
});
|
|
35
|
+
if (!putResponse.ok) {
|
|
36
|
+
const text = await putResponse.text().catch(() => "");
|
|
37
|
+
throw new Error(`Failed to reset password for RabbitMQ user "${username}" (${putResponse.status} ${putResponse.statusText}): ${text}`);
|
|
38
|
+
}
|
|
39
|
+
return password;
|
|
40
|
+
}
|
|
41
|
+
async function init(name) {
|
|
42
|
+
node_1.default.Cloud.define(name, async function (request) {
|
|
43
|
+
const { user } = request;
|
|
44
|
+
if (!user) {
|
|
45
|
+
throw new node_1.default.Error(node_1.default.Error.OBJECT_NOT_FOUND, "User must be logged in to reset RabbitMQ password.");
|
|
46
|
+
}
|
|
47
|
+
// Only allow users with "od-tenant-admin" role to reset passwords
|
|
48
|
+
const roleObjects = await new node_1.default.Query(node_1.default.Role)
|
|
49
|
+
.equalTo("users", request.user)
|
|
50
|
+
.find({ useMasterKey: true });
|
|
51
|
+
const roles = roleObjects
|
|
52
|
+
.map((role) => role.getName())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
const isTenantAdmin = roles.some((role) => role.startsWith("od-tenant-admin"));
|
|
55
|
+
if (!isTenantAdmin) {
|
|
56
|
+
throw new node_1.default.Error(node_1.default.Error.OBJECT_NOT_FOUND, "User must be a tenant admin to reset RabbitMQ password.");
|
|
57
|
+
}
|
|
58
|
+
const tenantid = user.get("tenant").id;
|
|
59
|
+
const password = await (0, createRandomPassword_js_1.createRandomPassword)();
|
|
60
|
+
const newPassword = await resetRabbitMQUserPassword(tenantid, password);
|
|
61
|
+
return { username: tenantid + ":" + tenantid, password: newPassword };
|
|
62
|
+
}, {
|
|
63
|
+
requireUser: true,
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/hooks/Permission.js
CHANGED
|
@@ -16,7 +16,7 @@ const masterKeyPermission = [
|
|
|
16
16
|
async function init() {
|
|
17
17
|
(0, index_js_1.beforeSaveHook)(index_js_2.Permission, async (request) => {
|
|
18
18
|
const { object, original, user } = request;
|
|
19
|
-
if (masterKeyPermission.includes(object.get("key")) && !request.master) {
|
|
19
|
+
if ((masterKeyPermission.includes(object.get("key")) || (object.get("key") && object.get("key").startsWith("master:"))) && !request.master) {
|
|
20
20
|
throw new node_1.default.Error(node_1.default.Error.OPERATION_FORBIDDEN, `User is not allowed to create or update permission with key '${object.get("key")}' without master key`);
|
|
21
21
|
}
|
|
22
22
|
await (0, index_js_1.defaultHandler)(request);
|
package/dist/hooks/Tenant.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.init = init;
|
|
7
7
|
const node_1 = __importDefault(require("parse/node"));
|
|
8
8
|
const index_js_1 = require("../features/schema/index.js");
|
|
9
|
+
const openinc_rmq_create_tenant_js_1 = require("../functions/openinc-rmq-create-tenant.js");
|
|
9
10
|
const index_js_2 = require("../types/index.js");
|
|
10
11
|
async function init() {
|
|
11
12
|
(0, index_js_1.beforeSaveHook)(index_js_2.Tenant, async (request) => {
|
|
@@ -70,7 +71,13 @@ async function init() {
|
|
|
70
71
|
},
|
|
71
72
|
}),
|
|
72
73
|
});
|
|
73
|
-
if (!original) {
|
|
74
|
+
if (!original && object.id && process.env.OPENINC_PARSE_CLOUD_RMQ_HOST) {
|
|
75
|
+
try {
|
|
76
|
+
await (0, openinc_rmq_create_tenant_js_1.createRabbitMQTenant)(object.id);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(`Failed to provision RabbitMQ tenant for ${object.id}:`, e);
|
|
80
|
+
}
|
|
74
81
|
// trigger beforeSave after creation
|
|
75
82
|
await object.save(null, {
|
|
76
83
|
useMasterKey: true,
|
package/dist/hooks/_ChangeLog.js
CHANGED
|
@@ -7,21 +7,22 @@ exports.init = init;
|
|
|
7
7
|
const node_1 = __importDefault(require("parse/node"));
|
|
8
8
|
const AMQPBusConnection_js_1 = require("../features/openware/services/AMQPBusConnection.js");
|
|
9
9
|
const index_js_1 = require("../features/schema/index.js");
|
|
10
|
-
const index_js_2 = require("../
|
|
10
|
+
const index_js_2 = require("../features/config/index.js");
|
|
11
|
+
const index_js_3 = require("../types/index.js");
|
|
11
12
|
async function init() {
|
|
12
13
|
const schema = await node_1.default.Schema.all();
|
|
13
|
-
const
|
|
14
|
-
console.log(`ChangeLog publishing enabled: ${
|
|
14
|
+
const config = index_js_2.ConfigInstance.getInstance();
|
|
15
|
+
console.log(`ChangeLog publishing enabled: ${config.getBoolean("CHANGELOG_PUBLISH_ENABLE")}`);
|
|
15
16
|
const blacklistedClasses = [
|
|
16
17
|
"_Session",
|
|
17
18
|
"_Installation",
|
|
18
|
-
...(
|
|
19
|
+
...(config.get("CHANGELOG_BLACKLIST") || "")
|
|
19
20
|
.split(",")
|
|
20
21
|
.map((c) => c.trim())
|
|
21
22
|
.filter((c) => c.length > 0),
|
|
22
23
|
];
|
|
23
24
|
console.log(`ChangeLog blacklisted classes: ${blacklistedClasses.join(", ") || "None"}`);
|
|
24
|
-
(0, index_js_1.beforeSaveHook)(
|
|
25
|
+
(0, index_js_1.beforeSaveHook)(index_js_3.Changelog.className, async (request) => {
|
|
25
26
|
const { object, original, user } = request;
|
|
26
27
|
await (0, index_js_1.defaultHandler)(request);
|
|
27
28
|
await (0, index_js_1.defaultAclHandler)(request);
|
|
@@ -29,16 +30,16 @@ async function init() {
|
|
|
29
30
|
object.getACL().setReadAccess(user.id, true);
|
|
30
31
|
}
|
|
31
32
|
});
|
|
32
|
-
if (
|
|
33
|
+
if (config.getBoolean("CHANGELOG_PUBLISH_ENABLE")) {
|
|
33
34
|
const params = {
|
|
34
|
-
host:
|
|
35
|
-
vhost:
|
|
36
|
-
port: parseInt(
|
|
37
|
-
username:
|
|
38
|
-
password:
|
|
39
|
-
exchange:
|
|
40
|
-
topicPrefix:
|
|
41
|
-
managementPort: parseInt(
|
|
35
|
+
host: config.get("CHANGELOG_PUBLISH_HOST") || "localhost",
|
|
36
|
+
vhost: config.get("CHANGELOG_PUBLISH_VHOST") || "configuration",
|
|
37
|
+
port: parseInt(config.get("CHANGELOG_PUBLISH_PORT") || "5672"),
|
|
38
|
+
username: config.get("CHANGELOG_PUBLISH_USERNAME") || "guest",
|
|
39
|
+
password: config.get("CHANGELOG_PUBLISH_PASSWORD") || "guest",
|
|
40
|
+
exchange: config.get("CHANGELOG_PUBLISH_EXCHANGE") || "topic",
|
|
41
|
+
topicPrefix: config.get("CHANGELOG_PUBLISH_TOPIC_PREFIX") || "config",
|
|
42
|
+
managementPort: parseInt(config.get("CHANGELOG_PUBLISH_MANAGEMENT_PORT") || "15672"),
|
|
42
43
|
};
|
|
43
44
|
const connection = new AMQPBusConnection_js_1.AMQPBusConnection(params);
|
|
44
45
|
try {
|
|
@@ -47,7 +48,7 @@ async function init() {
|
|
|
47
48
|
console.log(`Establishing Connection to ${url} for ChangeLog publishing...`);
|
|
48
49
|
await connection.connect();
|
|
49
50
|
console.log(`Connected to AMQPBus at ${url} for ChangeLog publishing.`);
|
|
50
|
-
(0, index_js_1.afterSaveHook)(
|
|
51
|
+
(0, index_js_1.afterSaveHook)(index_js_3.Changelog.className, async (request) => {
|
|
51
52
|
const { object: changelog } = request;
|
|
52
53
|
const msg = JSON.stringify(changelog.toJSON());
|
|
53
54
|
const topic = `${params.topicPrefix && params.topicPrefix.length > 0
|
|
@@ -63,8 +64,8 @@ async function init() {
|
|
|
63
64
|
}
|
|
64
65
|
schema.forEach((classSchema) => {
|
|
65
66
|
if (!classSchema.className ||
|
|
66
|
-
classSchema.className ===
|
|
67
|
-
classSchema.className ===
|
|
67
|
+
classSchema.className === index_js_3.Changelog.className ||
|
|
68
|
+
classSchema.className === index_js_3.Permission.className) {
|
|
68
69
|
console.log(`Skipping ChangeLog hook for class ${classSchema.className}`);
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
@@ -81,7 +82,7 @@ async function init() {
|
|
|
81
82
|
console.log(`Skipping ChangeLog entry for class ${classSchema.className} and object ${object.id} due to blacklisted class.`);
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
|
-
const changelog = new
|
|
85
|
+
const changelog = new index_js_3.Changelog();
|
|
85
86
|
changelog.set("nameOfClass", classSchema.className);
|
|
86
87
|
changelog.set("changedObject", object.id);
|
|
87
88
|
changelog.set("operation", "delete");
|
|
@@ -109,7 +110,7 @@ async function init() {
|
|
|
109
110
|
console.log(`Skipping ChangeLog entry for class ${classSchema.className} and object ${object.id} due to ${context.skipChangelog ? "context flag" : "blacklisted class"}.`);
|
|
110
111
|
return;
|
|
111
112
|
}
|
|
112
|
-
const changelog = new
|
|
113
|
+
const changelog = new index_js_3.Changelog();
|
|
113
114
|
changelog.set("nameOfClass", classSchema.className);
|
|
114
115
|
changelog.set("changedObject", object.id);
|
|
115
116
|
changelog.set("operation", original ? "update" : "create");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openinc/parse-server-opendash",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.34",
|
|
4
4
|
"description": "Parse Server Cloud Code for open.INC Stack.",
|
|
5
5
|
"packageManager": "pnpm@10.33.0",
|
|
6
6
|
"keywords": [
|
|
@@ -72,14 +72,14 @@
|
|
|
72
72
|
"dayjs": "^1.11.20",
|
|
73
73
|
"dotenv": "^17.4.2",
|
|
74
74
|
"fast-equals": "^6.0.0",
|
|
75
|
-
"i18next": "^26.0
|
|
75
|
+
"i18next": "^26.2.0",
|
|
76
76
|
"i18next-fs-backend": "^2.6.5",
|
|
77
77
|
"jsonwebtoken": "^9.0.3",
|
|
78
78
|
"jwks-rsa": "^4.0.1",
|
|
79
79
|
"nodemailer": "^8.0.7",
|
|
80
80
|
"nunjucks": "^3.2.4",
|
|
81
|
-
"parse": "^8.
|
|
82
|
-
"parse-server": "^9.
|
|
81
|
+
"parse": "^8.6.0",
|
|
82
|
+
"parse-server": "^9.9.0",
|
|
83
83
|
"pdf-img-convert": "2.0.0",
|
|
84
84
|
"rimraf": "^6.1.3",
|
|
85
85
|
"table": "^6.9.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@types/jsonwebtoken": "^9.0.10",
|
|
91
|
-
"@types/node": "^25.
|
|
91
|
+
"@types/node": "^25.9.0",
|
|
92
92
|
"@types/nodemailer": "^7.0.11",
|
|
93
93
|
"@types/nunjucks": "^3.2.6",
|
|
94
94
|
"@types/safe-timers": "^1.1.2",
|