New support: add moderator role

This commit is contained in:
yflory 2024-02-22 18:46:26 +01:00
parent a67b1ea7cc
commit 757f30d4a1
13 changed files with 472 additions and 58 deletions

View File

@ -14,6 +14,7 @@ const Core = require("./core");
const Channel = require("./channel");
const Invitation = require("./invitation");
const Users = require("./users");
const Moderators = require("./moderators");
const BlockStore = require("../storage/block");
const MFA = require("../storage/mfa");
const ArchiveAccount = require('../archive-account');
@ -918,6 +919,30 @@ var deleteInvitation = (Env, Server, cb, data) => {
Invitation.delete(Env, id, cb);
};
var getModerators = (Env, Server, cb) => {
Moderators.getAll(Env, cb);
};
var addModerator = (Env, Server, cb, data, unsafeKey) => {
const obj = Array.isArray(data) && data[1];
const name = obj.name;
const edPublic = obj.edPublic;
const curvePublic = obj.curvePublic;
const mailbox = obj.mailbox;
const profile = obj.profile;
const userData = {
name,
edPublic,
curvePublic,
mailbox,
profile
};
Moderators.add(Env, edPublic, userData, unsafeKey, cb);
};
var removeModerator = (Env, Server, cb, data) => {
const id = Array.isArray(data) && data[1];
Moderators.delete(Env, id, cb);
};
var commands = {
ACTIVE_SESSIONS: getActiveSessions,
ACTIVE_PADS: getActiveChannelCount,
@ -985,6 +1010,10 @@ var commands = {
ADD_KNOWN_USER: addKnownUser,
DELETE_KNOWN_USER: deleteKnownUser,
UPDATE_KNOWN_USER: updateKnownUser,
GET_MODERATORS: getModerators,
ADD_MODERATOR: addModerator,
REMOVE_MODERATOR: removeModerator,
};
// addFirstAdmin is an anon_rpc command

View File

@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/*jshint esversion: 6 */
const Moderators = module.exports;
const Moderator = require('../storage/moderator');
const Util = require("../common-util");
Moderators.getAll = (Env, cb) => {
Moderator.getAll(Env, (err, data) => {
if (err) { return void cb(err); }
cb(null, data);
});
};
Moderators.getKeysSync = (Env) => {
return Moderator.getAllKeys(Env);
};
Moderators.add = (Env, edPublic, data, adminKey, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
data.createdBy = adminKey;
data.time = +new Date();
const safeKey = Util.escapeKeyCharacters(edPublic);
Moderator.write(Env, safeKey, data, (err) => {
if (err) { return void cb(err); }
Env.moderators.push(edPublic);
Env.envUpdated.fire();
cb();
});
};
Moderators.delete = (Env, id, _cb) => {
const cb = Util.once(Util.mkAsync(_cb));
Moderator.delete(Env, id, (err) => {
if (err && err !== 'ENOENT') { return void cb(err); }
// XXX update Env.moderators
cb(void 0, true);
});
};

View File

@ -371,6 +371,14 @@ module.exports.create = function (config) {
console.error("Can't parse admin keys. Please update or fix your config.js file!");
}
try {
let moderators = require('./commands/moderators').getKeysSync(Env);
Env.moderators = moderators || [];
} catch (e) {
console.log(e);
console.error("Can't parse support keys.");
}
Env.envUpdated = Util.mkEvent();
Env.cacheFlushed = Util.mkEvent();

View File

@ -569,6 +569,7 @@ var serveConfig = makeRouteCache(function () {
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
moderatorKeys: Env.moderators,
inactiveTime: Env.inactiveTime,
supportMailbox: Env.supportMailbox,
supportMailboxKey: Env.supportMailboxKey,

View File

@ -45,6 +45,10 @@ Basic.readDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readdir(path, cb);
};
Basic.readDirSync = function (Env, path, cb) {
if (!path) { return []; }
return Fs.readdirSync(path);
};
Basic.write = function (Env, path, data, cb) {
if (!path) { return void pathError(cb); }

87
lib/storage/moderator.js Normal file
View File

@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const Basic = require("./basic.js");
const Path = require("node:path");
const nThen = require('nthen');
const Util = require('../common-util');
const Moderator = module.exports;
/* This module manages storage used to implement user management. "Known users" can
be added here in order to store their public key, their block ID and an alias
used to recognize them.
*/
const pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return void console.error('KNWONUSER_BAD_ID', id); }
return Path.join(Env.paths.base, "support", id.slice(0, 2), id);
};
Moderator.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, (err, data) => {
if (err) { return void cb(err.code); }
cb(void 0, Util.tryParse(data));
});
};
Moderator.getAllKeys = function (Env) {
let keys = [];
let dirPath = Path.join(Env.paths.base, "support");
try {
let prefixes = Basic.readDirSync(Env, dirPath);
prefixes.forEach((prefix) => {
let dirPath2 = Path.join(Env.paths.base, "support", prefix);
try {
let newKeys = Basic.readDirSync(Env, dirPath2);
keys.push(...newKeys);
} catch (e) {}
});
return keys;
} catch (e) {
return;
}
};
Moderator.getAll = function (Env, cb) {
let users = {};
nThen((waitFor) => {
let dirPath = Path.join(Env.paths.base, "support");
Basic.readDir(Env, dirPath, waitFor((err, prefixes) => {
if (err && err.code === 'ENOENT') { return void cb(void 0, {}); }
if (err) { waitFor.abort(); return void cb(err.code); }
prefixes.forEach((prefix) => {
var dirPath2 = Path.join(Env.paths.base, "support", prefix);
Basic.readDir(Env, dirPath2, waitFor((err, files) => {
if (err) { waitFor.abort(); return void cb(err.code); }
files.forEach((id) => {
Moderator.read(Env, id, waitFor((err, data) => {
users[id] = data || { error: err };
}));
});
}));
});
}));
}).nThen(() => {
cb(null, users);
});
};
Moderator.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, JSON.stringify(data), (err) => {
if (err) { return void cb(err.code); }
cb();
});
};
Moderator.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, (err) => {
if (err) { return void cb(err.code); }
cb();
});
};

View File

@ -2923,22 +2923,28 @@ Example
Messages.admin_supportNewDisabled = "Modern support system is disabled.";
Messages.admin_supportNewInit = "Initialize support page on this instance";
Messages.admin_supportNewDelete = "Disable support";
Messages.admin_supportNewConfirm = "Are you sure? This will remove access to all current moderators.";
Messages.admin_supportNewConfirm = "Are you sure? This will remove access to all current moderators and delete all existing tickets.";
Messages.admin_supportMembers = "Current support team";
Messages.admin_supportAdd = "Add a contact to the support team";
create['support-new'] = function () {
var $div = makeBlock('support-new'); // Msg.admin_supportNewHint, .admin_supportNewTitle
var newSupportKey = ApiConfig.supportMailboxKey;
(function () {
var state = h('div');
var $state = $(state).appendTo($div);
var button = h('button.btn.btn-primary', Messages.admin_supportNewInit);
var $button = $(button).appendTo($div);
var delButton = h('button.btn.btn-danger', Messages.admin_supportNewDelete);
var $delButton = $(delButton).appendTo($div).hide();
var spinner = UI.makeSpinner($div);
const $div = makeBlock('support-new'); // Msg.admin_supportNewHint, .admin_supportNewTitle
let supportKey = ApiConfig.supportMailboxKey;
let edPublic = common.getMetadataMgr().getPrivateData().edPublic; // My edPublic
let refresh = function () {};
const redraw = function (membersData, oldPrivKey) {
$div.empty();
var setState = function () {
const state = h('div');
const $state = $(state).appendTo($div);
const button = h('button.btn.btn-primary', Messages.admin_supportNewInit);
const $button = $(button).appendTo($div);
const delButton = h('button.btn.btn-danger', Messages.admin_supportNewDelete);
const $delButton = $(delButton).appendTo($div).hide();
const spinner = UI.makeSpinner($div);
const setState = function () {
$state.html('');
if (newSupportKey) {
if (supportKey) {
$button.hide();
$delButton.show();
return $state.append([
@ -2954,6 +2960,7 @@ Example
};
setState();
// XXX TODO add/remove access
// XXX when removing access, send new private keys to everybody who should keep access AND move chainpad doc to the new one
// ==> we'll need to delete the old chainpad doc using admin commands
@ -2967,7 +2974,7 @@ Example
$delButton.attr('disabled', 'disabled');
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['SET_SUPPORT_MAILBOX2', ['']]
data: ['SET_SUPPORT_MAILBOX2', ['', '']]
}, function (e, response) {
$delButton.removeAttr('disabled');
if (e || response.error) {
@ -2978,61 +2985,218 @@ Example
}
spinner.done();
UI.log(Messages.saved);
newSupportKey = undefined;
supportKey = undefined;
setState();
});
});
});
var next = function () {
/*
const getEncryptor = (curvePrivate) => {
const seed = curvePrivate.slice(0,24);
const hash = Hash.getEditHashFromKeys({
version: 2,
type: 'support',
keys: {
editKeyStr: seed
}
});
const secret = Hash.getSecrets('support', hash);
return Crypto.createEncryptor(secret.keys);
};
console.log(oldPrivKey, getEncryptor(oldPrivKey));
*/
const getMemberData = (curve) => {
let friends = common.getFriends(true);
let f = friends[curve || 'me'];
return {
name: f.displayName,
edPublic: f.edPublic,
curvePublic: f.curvePublic,
mailbox: f.notifications,
profile: f.profile
// XXX add avatar?
};
};
const generateKey = function () {
if (supportKey && !membersData[edPublic]) {
UI.alert("A support key already exists. You must be a moderator to generate a new one or delete the existing support data.");
return;
}
spinner.spin();
$button.attr('disabled', 'disabled');
var keyPair = Nacl.box.keyPair();
var pub = Nacl.util.encodeBase64(keyPair.publicKey);
var priv = Nacl.util.encodeBase64(keyPair.secretKey);
var ed = Nacl.sign.keyPair.fromSeed(keypair.secretKey);
var edPub = Nacl.util.encodeBase64(ed.publicKey);
// Store the private key first. It won't be used until the decree is accepted.
sFrameChan.query("Q_ADMIN_MAILBOX", {
version: 2,
priv: priv
}, function (err, obj) {
if (err || (obj && obj.error)) {
console.error(err || obj.error);
UI.warn(Messages.error);
spinner.hide();
return;
}
// Then send the decree
const keyPair = Nacl.box.keyPair();
const pub = Nacl.util.encodeBase64(keyPair.publicKey);
const priv = Nacl.util.encodeBase64(keyPair.secretKey);
const ed = Nacl.sign.keyPair.fromSeed(keypair.secretKey);
const edPub = Nacl.util.encodeBase64(ed.publicKey);
const onError = (waitFor, err, res) => {
if (waitFor) { waitFor.abort(); }
console.error(err, res);
spinner.hide();
UI.warn(Messages.error);
};
//const oldCrypto = supportKey ? getEncryptor(oldPrivKey) : undefined;
//const newCrypto = getEncryptor(priv);
nThen((waitFor) => {
// Send new key to server
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['SET_SUPPORT_MAILBOX2', [pub, edPub]]
}, function (e, response) {
}, waitFor((e, response) => {
$button.removeAttr('disabled');
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
spinner.hide();
return;
}
if (e || response.error) { return void onError(waitFor, e, response); }
}));
}).nThen((waitFor) => {
// Add to own mailbox
sFrameChan.query("Q_ADMIN_MAILBOX", {
version: 2,
priv: priv
}, waitFor((err, obj) => {
if (err || (obj && obj.error)) { return void onError(waitFor, err, obj); }
spinner.done();
UI.log(Messages.saved);
newSupportKey = pub;
supportKey = pub;
setState();
//$('.cp-admin-support-init').hide();
//APP.$rightside.append(create['support-list']());
//APP.$rightside.append(create['support-priv']());
}));
}).nThen(() => {
// Add myself to moderator role if not already there
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADD_MODERATOR',
data: getMemberData()
}, (e, response) => {
console.error(e, response);
});
}).nThen(() => {
// XXX migrate chainpad doc
// XXX delete old pin log
// XXX send new keys to members
console.error('XXX TODO send to members');
});
};
const addSupportMember = (curve, _cb) => {
let cb = Util.mkAsync(_cb);
let userData = getMemberData(curve);
if (!userData) { return void cb('INVALID_USER'); }
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADD_MODERATOR',
data: userData
}, (e, response) => {
if (e || (response && response.error)) {
return void cb(e || response.error);
}
// User added to support team in database, send them the keys
APP.supportModule.execCommand('ADD_MODERATOR', userData, (obj) => {
// XXX IF ERROR, (can't notify) remove from DB?
cb();
});
});
};
Util.onClickEnter($button, function () {
if (newSupportKey) {
/*
if (supportKey) {
return void UI.confirm(Messages.admin_supportNewConfirm, function (yes) {
if (yes) { next(); }
if (yes) { generateKey(); }
});
}
next();
*/
generateKey();
});
})();
const drawMembers = () => {
if (!supportKey) { return; }
const members = {};
const friends = Util.clone(common.getFriends(false))
Object.keys(membersData).forEach((ed) => {
let m = membersData[ed];
members[m.curvePublic] = {
displayName: m.name,
edPublic: m.edPublic,
profile: m.profile,
curvePublic: m.curvePublic,
notificatons: m.mailbox
};
});
Object.keys(friends).forEach((curve) => {
if (members[curve]) { delete friends[curve]; }
});
let currentList = UIElements.getUserGrid(Messages.admin_supportMembers, {
common: common,
list: true,
large: true,
noSelect: true,
data: members,
remove: (el) => {
console.error('REMOVE', el);
}
});
let contactsGrid = UIElements.getUserGrid(Messages.admin_supportAdd, {
common: common,
list: true,
large: true,
data: friends
}, function () {});
let addBtn = h('button.btn.btn-primary', 'ADD');
Util.onClickEnter($(addBtn), () => {
var $sel = $(contactsGrid.div).find('.cp-usergrid-user.cp-selected');
nThen((waitFor) => {
$sel.each((i, el) => {
let curve = $(el).attr('data-curve');
if (!curve) {
console.error('Missing data on selected user', el);
return void UI.warn(Messages.error);
}
addSupportMember(curve, waitFor());
});
}).nThen(() => {
refresh();
});
});
// Only moderators can add new moderators
if (!membersData[edPublic]) {
contactsGrid.div = undefined;
addBtn = undefined;
}
const list = h('div', [
currentList.div,
contactsGrid.div,
h('nev', addBtn)
]);
$div.append(list);
};
drawMembers();
};
refresh = () => {
let oldKey, members;
nThen((waitFor) => {
APP.supportModule.execCommand('GET_PRIVATE_KEY', {}, waitFor((obj) => {
oldKey = obj && obj.curvePrivate;
}));
}).nThen((waitFor) => {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'GET_MODERATORS',
data: {}
}, waitFor((e, response) => {
if (e || response.error) {
console.error(e || response.error);
UI.warn(Messages.error);
return;
}
members = response[0];
}));
}).nThen(() => {
redraw(members, oldKey);
});
};
refresh();
return $div;
};
create['support-init'] = function () {
@ -4096,6 +4260,7 @@ Example
APP.origin = privateData.origin;
APP.readOnly = privateData.readOnly;
APP.support = Support.create(common, true);
APP.supportModule = common.makeUniversal('support');
// Content

View File

@ -486,6 +486,7 @@ define([
// XXX
Messages.support_userNotification = "New support ticket or response: {0}";
Messages.support_moderatorNotification = "You have been added to the moderators list";
handlers['NOTIF_TICKET'] = function (common, data) {
var content = data.content;
var msg = content.msg.content;
@ -504,6 +505,19 @@ define([
content.dismissHandler = defaultDismiss(common, data);
}
};
handlers['ADD_MODERATOR'] = function (common, data) {
var content = data.content;
content.getFormatText = function () {
return Messages.support_moderatorNotification;
};
content.handler = function () {
common.openURL('/moderation/');
defaultDismiss(common, data)();
};
if (!content.archived) {
content.dismissHandler = defaultDismiss(common, data);
}
};
handlers['BROADCAST_CUSTOM'] = function (common, data) {
var content = data.content;

View File

@ -1627,7 +1627,7 @@ define([
var box = mailboxes[key] = {
channel: channel,
viewed: [],
lastKnownHash: '',
lastKnownHash: data.lastKnownHash || '',
keys: {
curvePublic: pub,
curvePrivate: priv

View File

@ -3,11 +3,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/api/config',
'/common/common-messaging.js',
'/common/common-hash.js',
'/common/common-util.js',
'/components/chainpad-crypto/crypto.js',
], function (Messaging, Hash, Util, Crypto) {
], function (ApiConfig, Messaging, Hash, Util, Crypto) {
// Random timeout between 10 and 30 times your sync time (lag + chainpad sync)
var getRandomTimeout = function (ctx) {
@ -908,6 +909,24 @@ define([
if (adminSupportNotif && adminSupportNotif.channel === id) { adminSupportNotif = undefined; }
};
handlers['ADD_MODERATOR'] = function (ctx, box, data, cb) {
var msg = data.msg;
var content = msg.content;
var newKey = content.supportKey;
// check if it matches the server key
var pub = Hash.getBoxPublicFromSecret(newKey);
if (pub !== ApiConfig.supportMailboxKey) { return void cb(true); }
// We have a correct key: add support mailbox
ctx.Store.addAdminMailbox(null, {
version: 2,
priv: newKey,
lastKnownHash: content.lastKnownHash
}, function (err) {
if (err) { return void cb(true); }
cb(false);
});
};
return {
add: function (ctx, box, data, cb) {
/**

View File

@ -22,7 +22,7 @@ define([
var cb = Util.mkAsync(_cb);
if (isAdmin && !ctx.adminRdyEvt) { return void cb('EFORBIDDEN'); }
require(['/api/config?' + (+new Date())], function (NewConfig) {
ctx.adminKeys = NewConfig.adminKeys; // Update admin keys // XXX MODERATOR
ctx.moderatorKeys = NewConfig.moderatorKeys; // Update admin keys // XXX MODERATOR
var supportKey = NewConfig.supportMailboxKey;
if (!supportKey) { return void cb('E_NOT_INIT'); }
@ -324,7 +324,7 @@ define([
entry.premium = premium;
if (senderKey) {
entry.lastAdmin = ctx.adminKeys.indexOf(senderKey) !== -1
entry.lastAdmin = ctx.moderatorKeys.indexOf(senderKey) !== -1
}
}
}
@ -544,7 +544,7 @@ define([
if (err) { console.error('Support RPC not ready', err); }
}));
}).nThen((waitFor) => {
let seed = privateKey.slice(0,24); // XXX better way to get seed?
let seed = privateKey.slice(0,24); // XXX better way to get seed? also in admin/inner.js
let hash = Hash.getEditHashFromKeys({
version: 2,
type: 'support',
@ -559,6 +559,44 @@ define([
};
var getAdminKey = function (ctx, data, cId, cb) {
let proxy = ctx.store.proxy;
let supportKey = Util.find(proxy, ['mailboxes', 'supportteam', 'keys', 'curvePublic']);
let privateKey = Util.find(proxy, ['mailboxes', 'supportteam', 'keys', 'curvePrivate']);
getKeys(ctx, false, {}, (err, obj) => {
if (err) { return void cb({error: err}); }
if (obj.theirPublic !== supportKey) { return void cb({ error: 'EFORBIDDEN' }); }
cb({
curvePrivate: privateKey
});
});
};
var addModerator = function (ctx, data, cId, cb) {
let proxy = ctx.store.proxy;
var mailbox = Util.find(ctx, [ 'store', 'mailbox' ]);
let supportKey = Util.find(proxy, ['mailboxes', 'supportteam', 'keys', 'curvePublic']);
let privateKey = Util.find(proxy, ['mailboxes', 'supportteam', 'keys', 'curvePrivate']);
let lastKnownHash = Util.find(proxy, ['mailboxes', 'supportteam', 'lastKnownHash']);
let edPublic = proxy.edPublic;
// Confirm that I know the latest private key
getKeys(ctx, false, {}, (err, obj) => {
if (err) { return void cb({error: err}); }
if (obj.theirPublic !== supportKey) { return void cb({ error: 'EFORBIDDEN' }); }
if (!ctx.moderatorKeys.includes(edPublic)) { return void cb({ error: 'EFORBIDDEN' }); }
// Send this private key to the selected user
mailbox.sendTo('ADD_MODERATOR', {
supportKey: privateKey,
lastKnownHash
}, {
channel: data.mailbox,
curvePublic: data.curvePublic
}, () => {
cb();
});
});
};
Support.init = function (cfg, waitFor, emit) {
var support = {};
@ -572,7 +610,7 @@ define([
var proxy = store.proxy.support = store.proxy.support || {};
var ctx = {
adminKeys: ApiConfig.adminKeys,
moderatorKeys: ApiConfig.moderatorKeys,
supportData: proxy,
store: cfg.store,
Store: cfg.Store,
@ -621,6 +659,12 @@ define([
if (cmd === 'CLOSE_TICKET_ADMIN') {
return void closeTicketAdmin(ctx, data, clientId, cb);
}
if (cmd === 'GET_PRIVATE_KEY') {
return void getAdminKey(ctx, data, clientId, cb);
}
if (cmd === 'ADD_MODERATOR') {
return void addModerator(ctx, data, clientId, cb);
}
if (cmd === 'GET_MY_TICKETS') {
return void getMyTickets(ctx, data, clientId, cb);
}

View File

@ -301,7 +301,8 @@ define([
var privateData = metadataMgr.getPrivateData();
common.setTabTitle(Messages.supportPage);
if (!common.isAdmin()) {
if (!Array.isArray(ApiConfig.moderatorKeys) ||
!ApiConfig.moderatorKeys.includes(privateData.edPublic)) {
return void UI.errorLoadingScreen(Messages.admin_authError || '403 Forbidden');
}

View File

@ -423,7 +423,7 @@ define([
// Check content.sender to see if it comes from us or from an admin
var senderKey = content.sender && content.sender.edPublic;
var fromMe = senderKey === privateData.edPublic;
var fromAdmin = ctx.adminKeys.indexOf(senderKey) !== -1
var fromAdmin = ctx.moderatorKeys.indexOf(senderKey) !== -1
|| (!senderKey && content.sender.accountName === 'support'); // XXX anon key?
var fromPremium = Boolean(content.sender.plan || Util.find(content, ['sender', 'quota', 'plan']));
@ -498,7 +498,7 @@ define([
var senderKey = content.sender && content.sender.edPublic;
var fromMe = senderKey === privateData.edPublic;
var fromAdmin = ctx.adminKeys.indexOf(senderKey) !== -1;
var fromAdmin = ctx.moderatorKeys.indexOf(senderKey) !== -1;
var adminClass = (fromAdmin? '.cp-support-fromadmin': '');
var name = Util.fixHTML(content.sender.name) || Messages.anonymous;
@ -518,7 +518,7 @@ define([
isAdmin: isAdmin,
pinUsage: pinUsage || false,
teamsUsage: teamsUsage || false,
adminKeys: Array.isArray(ApiConfig.adminKeys)? ApiConfig.adminKeys.slice(): [],
moderatorKeys: Array.isArray(ApiConfig.moderatorKeys)? ApiConfig.moderatorKeys.slice(): [],
};
ctx.supportModule = common.makeUniversal('support');