vimp-engine 0.38.1 → 0.38.3
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/LICENSE +21 -0
- package/README.md +12 -6
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +4 -3
- package/package.json +14 -5
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/view/Auth.js +4 -2
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/formBuilder.js +9 -8
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/main.js +46 -28
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostController.js +4 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/WebSocketTransport.js +1 -1
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +5 -6
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +6 -5
- package/src/client/views/includes/reports.pug +1 -1
- package/src/config/authClient.js +4 -5
- package/src/config/gameCodes.js +2 -2
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/wsports.js +2 -2
- package/src/devtools/builtinScenario.js +3 -3
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
- package/src/devtools/contract/rules/a4-vite-config.js +4 -3
- package/src/devtools/contract/rules/a7-package-repository.js +7 -7
- package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
- package/src/devtools/contract/rules/index.js +1 -1
- package/src/devtools/invariants.js +2 -2
- package/src/devtools/pluginLoader.js +3 -3
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +35 -15
- package/src/host/PortMachine.js +6 -5
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +8 -2
- package/src/host/meta/SocketManager.js +2 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/capabilities.js +1 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +1 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/packageLink.js +21 -63
- package/src/lib/validators.js +3 -3
- package/src/standalone/index.js +6 -6
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/bin/vimp-surface.js +0 -88
- package/src/config/env.js +0 -104
- package/src/config/master.js +0 -288
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
- package/src/lib/clientIp.js +0 -96
- package/src/lib/rateLimiter.js +0 -40
- package/src/lib/security.js +0 -45
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import wsports from '../../config/wsports.js';
|
|
5
|
-
import {
|
|
6
|
-
KNOWN_GAME_CONFIG_PATHS,
|
|
7
|
-
REQUIRED_GAME_CONFIG_PATHS,
|
|
8
|
-
} from '../../lib/gameConfigView.js';
|
|
9
|
-
import { formControls } from '../../lib/formControls.js';
|
|
10
|
-
import { ENGINE_CAPABILITIES } from '../../lib/capabilities.js';
|
|
11
|
-
import { clientServices } from '../../config/clientServices.js';
|
|
12
|
-
import { abiOps } from '../../config/abiOps.js';
|
|
13
|
-
import { SNAPSHOT_FORMAT_VERSION } from '../../config/opcodes.js';
|
|
14
|
-
import { parseAbi } from './abiParse.js';
|
|
15
|
-
|
|
16
|
-
// Слепок плагинной поверхности (этап 1 плана plugin-forward-compat).
|
|
17
|
-
// Поверхность — всё, что игра может написать или прочитать: поля gameConfig,
|
|
18
|
-
// имена сервисов, контролы форм, номера портов, поля манифеста, члены
|
|
19
|
-
// объектов плагина, методы wasm-ABI. Инвариант И1 говорит, что ни одно из
|
|
20
|
-
// этих имён не исчезает и не переименовывается, И3 — что форма данных
|
|
21
|
-
// (сигнатура ABI, номер порта) не меняется.
|
|
22
|
-
//
|
|
23
|
-
// Слепок ничего не объявляет сам: каждый раздел собирается из существующего
|
|
24
|
-
// модуля движка — импортом там, где имя есть значением, и разбором текста
|
|
25
|
-
// там, где его нет (методы внутри `macro_rules!`, поля манифеста, которые
|
|
26
|
-
// движок читает точечно). Продублированный руками список устарел бы молча.
|
|
27
|
-
//
|
|
28
|
-
// Живёт в devtools/, потому что в бандл приложения devtools не попадает
|
|
29
|
-
// (граница из CLAUDE.md).
|
|
30
|
-
|
|
31
|
-
const SRC_DIR = fileURLToPath(new URL('../../', import.meta.url));
|
|
32
|
-
const ABI_PATH = fileURLToPath(
|
|
33
|
-
new URL('../../../core/src/abi.rs', import.meta.url),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
// модули, читающие GameManifest: поля манифеста — их точечные обращения
|
|
37
|
-
const MANIFEST_READERS = [
|
|
38
|
-
'master/GameCatalog.js',
|
|
39
|
-
'lib/loadGamePackage.js',
|
|
40
|
-
'lib/gamePlugin.js',
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Собирает поверхность плагинного контракта.
|
|
45
|
-
* @returns {Promise<Object>} JSON-объект слепка (см. contract/surface.json).
|
|
46
|
-
*/
|
|
47
|
-
export async function collectSurface() {
|
|
48
|
-
const abi = parseAbi(await readFile(ABI_PATH, 'utf8'));
|
|
49
|
-
const sources = await readSources();
|
|
50
|
-
|
|
51
|
-
return sortDeep({
|
|
52
|
-
abi,
|
|
53
|
-
// опкоды dispatch: append-only на тех же правилах, что имена (И1) —
|
|
54
|
-
// удаление опкода отнимает возможность, на которую игра уже оперлась
|
|
55
|
-
abiOps: registrySection(abiOps),
|
|
56
|
-
clientPluginMembers: collectMembers(sources, 'clientPlugin'),
|
|
57
|
-
// словари-реестры отдают и активные записи, и выведенные из
|
|
58
|
-
// эксплуатации: вывод алиасом — не удаление (И1), и слепок обязан
|
|
59
|
-
// отличать одно от другого
|
|
60
|
-
clientServices: registrySection(clientServices),
|
|
61
|
-
// возможности движка (этап 5): имя отсюда игра пишет в
|
|
62
|
-
// manifest.requires — исчезнувшее имя отвергает уже опубликованную игру
|
|
63
|
-
// ровно так же, как исчезнувший контрол формы
|
|
64
|
-
engineCapabilities: registrySection(ENGINE_CAPABILITIES),
|
|
65
|
-
formControls: registrySection(formControls),
|
|
66
|
-
// поля gameConfig с умолчаниями (И1): движок читает их через view, а не
|
|
67
|
-
// точечно с плагина, поэтому в hostPluginMembers они уже не видны
|
|
68
|
-
gameConfigFields: [
|
|
69
|
-
...KNOWN_GAME_CONFIG_PATHS,
|
|
70
|
-
...REQUIRED_GAME_CONFIG_PATHS,
|
|
71
|
-
],
|
|
72
|
-
hostPluginMembers: collectMembers(sources, 'hostPlugin'),
|
|
73
|
-
manifestFields: collectManifestFields(sources),
|
|
74
|
-
ports: { server: { ...wsports.server }, client: { ...wsports.client } },
|
|
75
|
-
requiredGameConfig: [...REQUIRED_GAME_CONFIG_PATHS],
|
|
76
|
-
// движковая (не плагинная) величина: плагин её не читает, но смена
|
|
77
|
-
// раскладки кадра разводит хост и клиент разных сборок движка — пусть
|
|
78
|
-
// попадает в diff и меняется осознанно
|
|
79
|
-
snapshotFormatVersion: SNAPSHOT_FORMAT_VERSION,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// запись реестра в слепке: имя плюс механика вывода из эксплуатации.
|
|
84
|
-
// `note` не пишем — это пояснение для человека, а не форма данных
|
|
85
|
-
function registrySection(registry) {
|
|
86
|
-
return registry.list().map(({ value, since, alias, retiredIn }) => ({
|
|
87
|
-
name: value,
|
|
88
|
-
since,
|
|
89
|
-
...(alias === undefined ? {} : { alias, retiredIn }),
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// текст всех модулей движка без комментариев и строковых литералов: путь
|
|
94
|
-
// 'manifest.json' в строке — не чтение поля манифеста, и попади он в слепок,
|
|
95
|
-
// раздел жил бы своей жизнью
|
|
96
|
-
async function readSources() {
|
|
97
|
-
const files = await listJsFiles(SRC_DIR);
|
|
98
|
-
const sources = new Map();
|
|
99
|
-
|
|
100
|
-
for (const file of files) {
|
|
101
|
-
const rel = path.relative(SRC_DIR, file).replaceAll(path.sep, '/');
|
|
102
|
-
|
|
103
|
-
sources.set(rel, stripNoise(await readFile(file, 'utf8')));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return sources;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function listJsFiles(dir) {
|
|
110
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
111
|
-
const files = [];
|
|
112
|
-
|
|
113
|
-
for (const entry of entries) {
|
|
114
|
-
// `_`-префикс — черновики, которые не коммитятся (CLAUDE.md)
|
|
115
|
-
if (entry.name.startsWith('_')) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const full = path.join(dir, entry.name);
|
|
120
|
-
|
|
121
|
-
if (entry.isDirectory()) {
|
|
122
|
-
files.push(...(await listJsFiles(full)));
|
|
123
|
-
} else if (entry.name.endsWith('.js')) {
|
|
124
|
-
files.push(full);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return files;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// комментарии и литералы вырезаются одним проходом, в порядке появления:
|
|
132
|
-
// раздельные проходы спаривают апостроф внутри шаблонной строки с чужим
|
|
133
|
-
// апострофом и молча съедают код между ними
|
|
134
|
-
const NOISE =
|
|
135
|
-
/\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
|
|
136
|
-
|
|
137
|
-
function stripNoise(source) {
|
|
138
|
-
return source.replace(NOISE, ' ');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// имена, которые движок читает с объекта плагина: точечные обращения
|
|
142
|
-
// (`hostPlugin.gameConfig`, `clientPlugin.hooks.onAuth`) и деструктуризация
|
|
143
|
-
function collectMembers(sources, holder) {
|
|
144
|
-
const names = new Set();
|
|
145
|
-
|
|
146
|
-
for (const source of sources.values()) {
|
|
147
|
-
for (const name of readPaths(source, holder)) {
|
|
148
|
-
names.add(name);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (names.size === 0) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
`collectSurface: no member of "${holder}" is read anywhere in ` +
|
|
155
|
-
'src/ — the engine was restructured; fix the collector instead of ' +
|
|
156
|
-
'letting the surface snapshot go silently empty',
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return [...names];
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function collectManifestFields(sources) {
|
|
164
|
-
const names = new Set();
|
|
165
|
-
|
|
166
|
-
for (const file of MANIFEST_READERS) {
|
|
167
|
-
const source = sources.get(file);
|
|
168
|
-
|
|
169
|
-
if (source === undefined) {
|
|
170
|
-
throw new Error(
|
|
171
|
-
`collectSurface: manifest reader ${file} is gone — the engine was ` +
|
|
172
|
-
'restructured; fix the collector',
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
for (const name of readPaths(source, 'manifest')) {
|
|
177
|
-
names.add(name);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (names.size === 0) {
|
|
182
|
-
throw new Error(
|
|
183
|
-
'collectSurface: no GameManifest field is read anywhere in ' +
|
|
184
|
-
`${MANIFEST_READERS.join(', ')} — fix the collector`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return [...names];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// пути, которые модуль читает с объекта `holder`: `holder.a.b` → 'a.b',
|
|
192
|
-
// `const { a, b } = holder` → 'a', 'b'
|
|
193
|
-
function readPaths(source, holder) {
|
|
194
|
-
const names = [];
|
|
195
|
-
const dotted = new RegExp(
|
|
196
|
-
`\\b${holder}\\??\\.([A-Za-z_$][\\w$]*(?:\\??\\.[A-Za-z_$][\\w$]*)*)`,
|
|
197
|
-
'g',
|
|
198
|
-
);
|
|
199
|
-
let match;
|
|
200
|
-
|
|
201
|
-
while ((match = dotted.exec(source)) !== null) {
|
|
202
|
-
names.push(match[1].replaceAll('?.', '.'));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ровно `= holder`, а не `= holder.gameConfig`: во втором случае имена
|
|
206
|
-
// принадлежат не плагину, а его конфигу — у того свой раздел слепка
|
|
207
|
-
const destructured = new RegExp(
|
|
208
|
-
`\\{([^{}]*)\\}\\s*=\\s*${holder}(?![\\w$.])`,
|
|
209
|
-
'g',
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
while ((match = destructured.exec(source)) !== null) {
|
|
213
|
-
for (const part of match[1].split(',')) {
|
|
214
|
-
const name = part
|
|
215
|
-
.split(':')[0]
|
|
216
|
-
.trim()
|
|
217
|
-
.replace(/^\.\.\./, '');
|
|
218
|
-
|
|
219
|
-
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
|
|
220
|
-
names.push(name);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return names;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// списки, порядок которых сам по себе является контрактом: аргументы
|
|
229
|
-
// ABI-метода читаются позиционно, и алфавит здесь тихо переписал бы
|
|
230
|
-
// сигнатуру
|
|
231
|
-
const ORDERED_LISTS = ['args'];
|
|
232
|
-
|
|
233
|
-
// стабильный порядок: ключи объектов и строковые списки — по алфавиту,
|
|
234
|
-
// записи ABI — по имени. Слепок читается диффом, а не глазами
|
|
235
|
-
function sortDeep(value, key = null) {
|
|
236
|
-
if (Array.isArray(value)) {
|
|
237
|
-
const items = value.map(item => sortDeep(item));
|
|
238
|
-
|
|
239
|
-
if (ORDERED_LISTS.includes(key)) {
|
|
240
|
-
return items;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return items.every(item => typeof item === 'string')
|
|
244
|
-
? items.sort()
|
|
245
|
-
: items.sort((a, b) => String(a?.name).localeCompare(String(b?.name)));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (value !== null && typeof value === 'object') {
|
|
249
|
-
return Object.fromEntries(
|
|
250
|
-
Object.keys(value)
|
|
251
|
-
.sort()
|
|
252
|
-
.map(name => [name, sortDeep(value[name], name)]),
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return value;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Сериализация слепка для записи в contract/surface.json.
|
|
261
|
-
* @param {Object} surface - Результат collectSurface().
|
|
262
|
-
* @returns {string} JSON с отступом 2 и переводом строки в конце.
|
|
263
|
-
*/
|
|
264
|
-
export function formatSurface(surface) {
|
|
265
|
-
return `${JSON.stringify(sortDeep(surface), null, 2)}\n`;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const INVARIANT_1 =
|
|
269
|
-
'Инвариант И1 (plan/plugin-forward-compat/README.md): имя, которое игра\n' +
|
|
270
|
-
'могла написать, существует вечно. Выведи его из эксплуатации алиасом,\n' +
|
|
271
|
-
'а не удалением. Если это осознанный security-фикс — удали строку из\n' +
|
|
272
|
-
'contract/surface.json тем же коммитом и опиши в CHANGELOG под\n' +
|
|
273
|
-
'⚠️ Breaking + Migration.';
|
|
274
|
-
|
|
275
|
-
const INVARIANT_2 =
|
|
276
|
-
'Инвариант И2 (plan/plugin-forward-compat/README.md): ничто новое не\n' +
|
|
277
|
-
'обязательно. Новое обязательное поле gameConfig отвергает КАЖДУЮ уже\n' +
|
|
278
|
-
'опубликованную игру, которая о нём не знает. Заведи поле в FIELDS\n' +
|
|
279
|
-
'модуля lib/gameConfigView.js с безопасным умолчанием — список REQUIRED\n' +
|
|
280
|
-
'может только сокращаться.';
|
|
281
|
-
|
|
282
|
-
const INVARIANT_3 =
|
|
283
|
-
'Инвариант И3 (plan/plugin-forward-compat/README.md): форма данных\n' +
|
|
284
|
-
'неизменна — рядом добавляется новая. Уже опубликованная сборка игры\n' +
|
|
285
|
-
'вызывает старую форму и не пересоберётся. Если это осознанный слом —\n' +
|
|
286
|
-
'поправь строку в contract/surface.json тем же коммитом и опиши в\n' +
|
|
287
|
-
'CHANGELOG под ⚠️ Breaking + Migration.';
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Сравнение закоммиченного слепка с собранным.
|
|
291
|
-
* @param {Object} committed - Слепок из contract/surface.json.
|
|
292
|
-
* @param {Object} collected - Результат collectSurface().
|
|
293
|
-
* @returns {{violations: string[], additions: string[]}} Нарушения И1/И3 и
|
|
294
|
-
* добавления (добавление поверхности совместимость не ломает).
|
|
295
|
-
*/
|
|
296
|
-
export function diffSurface(committed, collected) {
|
|
297
|
-
const violations = [];
|
|
298
|
-
const additions = [];
|
|
299
|
-
|
|
300
|
-
walk(committed, collected, [], violations, additions);
|
|
301
|
-
|
|
302
|
-
return { violations, additions };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function walk(before, after, trail, violations, additions) {
|
|
306
|
-
const where = trail.join('.') || 'surface';
|
|
307
|
-
|
|
308
|
-
if (Array.isArray(before)) {
|
|
309
|
-
walkList(before, after ?? [], where, violations, additions);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (before !== null && typeof before === 'object') {
|
|
314
|
-
for (const key of Object.keys(before)) {
|
|
315
|
-
if (after?.[key] === undefined) {
|
|
316
|
-
violations.push(`surface: '${key}' исчез из ${where}.\n${INVARIANT_1}`);
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
walk(before[key], after[key], [...trail, key], violations, additions);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
for (const key of Object.keys(after ?? {})) {
|
|
324
|
-
if (before[key] === undefined) {
|
|
325
|
-
additions.push(`${where}.${key}`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (before !== after) {
|
|
333
|
-
violations.push(
|
|
334
|
-
`surface: ${where} изменился: ${JSON.stringify(before)} → ` +
|
|
335
|
-
`${JSON.stringify(after)}.\n` +
|
|
336
|
-
(ENGINE_VALUES.has(where) ? ENGINE_VALUE_NOTE : INVARIANT_3),
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Движковые величины: плагин их не читает, поэтому И3 к ним не относится —
|
|
342
|
-
// но менять их молча всё равно нельзя.
|
|
343
|
-
const ENGINE_VALUES = new Set(['snapshotFormatVersion']);
|
|
344
|
-
|
|
345
|
-
const ENGINE_VALUE_NOTE =
|
|
346
|
-
'Это движковая величина, а не плагинная: игра её не читает и от неё не\n' +
|
|
347
|
-
'зависит. Но раскладка кадра разводит хост и клиент разных сборок\n' +
|
|
348
|
-
'движка — если смена осознанна, поправь contract/surface.json тем же\n' +
|
|
349
|
-
'коммитом и убедись, что расхождение ловит codeVersion.';
|
|
350
|
-
|
|
351
|
-
// Разделы с инвертированным правилом: у них расширение — это слом, а
|
|
352
|
-
// сокращение — норма. Пока такой один: список обязательных полей
|
|
353
|
-
// gameConfig (И2).
|
|
354
|
-
const SHRINK_ONLY = new Set(['requiredGameConfig']);
|
|
355
|
-
|
|
356
|
-
// требование, которого раньше не было, отвергает старые игры — а снятое
|
|
357
|
-
// требование не ломает никого: правило зеркально общему
|
|
358
|
-
function walkShrinkOnly(before, after, where, violations, additions) {
|
|
359
|
-
const known = new Set(before);
|
|
360
|
-
|
|
361
|
-
for (const name of after) {
|
|
362
|
-
if (!known.has(name)) {
|
|
363
|
-
violations.push(
|
|
364
|
-
`surface: '${name}' добавлен в ${where}.\n${INVARIANT_2}`,
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const kept = new Set(after);
|
|
370
|
-
|
|
371
|
-
for (const name of before) {
|
|
372
|
-
if (!kept.has(name)) {
|
|
373
|
-
additions.push(`${where}: снято требование ${name}`);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function walkList(before, after, where, violations, additions) {
|
|
379
|
-
if (SHRINK_ONLY.has(where)) {
|
|
380
|
-
walkShrinkOnly(before, after, where, violations, additions);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const key = item => (typeof item === 'string' ? item : item?.name);
|
|
385
|
-
const seen = new Map(after.map(item => [key(item), item]));
|
|
386
|
-
|
|
387
|
-
for (const item of before) {
|
|
388
|
-
const name = key(item);
|
|
389
|
-
const found = seen.get(name);
|
|
390
|
-
|
|
391
|
-
if (found === undefined) {
|
|
392
|
-
violations.push(`surface: '${name}' исчез из ${where}.\n${INVARIANT_1}`);
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (JSON.stringify(found) !== JSON.stringify(item)) {
|
|
397
|
-
violations.push(
|
|
398
|
-
`surface: сигнатура '${name}' в ${where} изменилась: ` +
|
|
399
|
-
`${JSON.stringify(item)} → ${JSON.stringify(found)}.\n${INVARIANT_3}`,
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const known = new Set(before.map(key));
|
|
405
|
-
|
|
406
|
-
for (const item of after) {
|
|
407
|
-
if (!known.has(key(item))) {
|
|
408
|
-
additions.push(`${where}: ${key(item)}`);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export default collectSurface;
|
package/src/lib/clientIp.js
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// Адрес клиента для rate-limit'ов серверных контуров.
|
|
2
|
-
//
|
|
3
|
-
// `X-Forwarded-For` для этого не годится: Nginx деплоя ставит его через
|
|
4
|
-
// `$proxy_add_x_forwarded_for` (.github/deployment/install-system.sh), то есть
|
|
5
|
-
// ДОПИСЫВАЕТ реальный адрес к тому, что прислал клиент. Первый элемент списка
|
|
6
|
-
// задаёт сам клиент — на нём лимит и обходится (свой заголовок на каждое
|
|
7
|
-
// соединение), и выбирается чужой бакет (заголовок с адресом жертвы закрывает
|
|
8
|
-
// ей вход). Тот же Nginx перезаписывает `X-Real-IP` значением `$remote_addr`,
|
|
9
|
-
// поэтому за прокси доверяем только ему, а без прокси — адресу сокета.
|
|
10
|
-
|
|
11
|
-
// один раз на процесс: предупреждение о прокси без X-Real-IP на каждое
|
|
12
|
-
// соединение превратилось бы в лог-флуд
|
|
13
|
-
let proxyHeaderWarned = false;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {Object} req - Запрос (http.IncomingMessage или express Request).
|
|
17
|
-
* @param {Object} [options]
|
|
18
|
-
* @param {boolean} [options.trustProxy] - Стоит ли перед процессом обратный
|
|
19
|
-
* прокси, перезаписывающий `X-Real-IP` (в этом репозитории — прод-Nginx).
|
|
20
|
-
* @returns {string} Адрес клиента или '' (сокет уже разорван).
|
|
21
|
-
*/
|
|
22
|
-
export function clientIp(req, { trustProxy = false } = {}) {
|
|
23
|
-
const header = trustProxy ? req.headers['x-real-ip'] : undefined;
|
|
24
|
-
|
|
25
|
-
// за прокси без X-Real-IP ключом станет адрес самого прокси — один общий
|
|
26
|
-
// бакет на всех клиентов сразу: правило «одна комната на IP» пустило бы
|
|
27
|
-
// одну комнату на весь мастер, а лимит пингов стал бы общим. Падать на
|
|
28
|
-
// первом запросе нельзя (сервер должен подняться), молчать — тоже
|
|
29
|
-
if (trustProxy && !header && !proxyHeaderWarned) {
|
|
30
|
-
proxyHeaderWarned = true;
|
|
31
|
-
console.warn(
|
|
32
|
-
'[clientIp] trustProxy is on but no X-Real-IP header arrived: every ' +
|
|
33
|
-
"client now keys on the proxy's own address, one shared rate-limit " +
|
|
34
|
-
'bucket. Check `proxy_set_header X-Real-IP $remote_addr`.',
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return String(header || req.socket?.remoteAddress || '').trim();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Ключ rate-limit'а по адресу: IPv4 — сам адрес, IPv6 — подсеть /64.
|
|
42
|
-
// Провайдер выдаёт абоненту /64 целиком, то есть у одного человека
|
|
43
|
-
// 2^64 адресов, и лимит «на адрес» для IPv6 не лимит вовсе
|
|
44
|
-
export function rateLimitKey(ip) {
|
|
45
|
-
const addr = String(ip ?? '').trim();
|
|
46
|
-
|
|
47
|
-
if (!addr) {
|
|
48
|
-
return '';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr);
|
|
52
|
-
|
|
53
|
-
if (mapped) {
|
|
54
|
-
return mapped[1];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!addr.includes(':')) {
|
|
58
|
-
return addr;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const groups = expandIpv6(addr.split('%')[0]);
|
|
62
|
-
|
|
63
|
-
if (!groups) {
|
|
64
|
-
return addr;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return `v6:${groups
|
|
68
|
-
.slice(0, 4)
|
|
69
|
-
.map(g => parseInt(g, 16).toString(16))
|
|
70
|
-
.join(':')}::/64`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// '2001:db8::1' → 8 hex-групп или null, если запись непарсима
|
|
74
|
-
function expandIpv6(addr) {
|
|
75
|
-
const halves = addr.split('::');
|
|
76
|
-
|
|
77
|
-
if (halves.length > 2) {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const head = halves[0] ? halves[0].split(':') : [];
|
|
82
|
-
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
83
|
-
const missing = 8 - head.length - tail.length;
|
|
84
|
-
|
|
85
|
-
if (halves.length === 1 ? missing !== 0 : missing < 1) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const groups = [
|
|
90
|
-
...head,
|
|
91
|
-
...Array(halves.length === 2 ? missing : 0).fill('0'),
|
|
92
|
-
...tail,
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
return groups.every(g => /^[0-9a-f]{1,4}$/i.test(g)) ? groups : null;
|
|
96
|
-
}
|
package/src/lib/rateLimiter.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// Rate limiter с фиксированным окном: не более `limit` событий
|
|
2
|
-
// на ключ (например, IP) за `windowMs` миллисекунд.
|
|
3
|
-
export default class RateLimiter {
|
|
4
|
-
constructor({ limit, windowMs }) {
|
|
5
|
-
this._limit = limit;
|
|
6
|
-
this._windowMs = windowMs;
|
|
7
|
-
this._buckets = new Map(); // key -> { count, windowStart }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// регистрирует событие; false — лимит для ключа исчерпан
|
|
11
|
-
consume(key, now = Date.now()) {
|
|
12
|
-
const bucket = this._buckets.get(key);
|
|
13
|
-
|
|
14
|
-
if (!bucket || now - bucket.windowStart >= this._windowMs) {
|
|
15
|
-
this._buckets.set(key, { count: 1, windowStart: now });
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (bucket.count >= this._limit) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
bucket.count += 1;
|
|
24
|
-
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// удаляет истёкшие окна (периодическая уборка памяти)
|
|
29
|
-
sweep(now = Date.now()) {
|
|
30
|
-
for (const [key, bucket] of this._buckets) {
|
|
31
|
-
if (now - bucket.windowStart >= this._windowMs) {
|
|
32
|
-
this._buckets.delete(key);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
clear() {
|
|
38
|
-
this._buckets.clear();
|
|
39
|
-
}
|
|
40
|
-
}
|
package/src/lib/security.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import config from './config.js';
|
|
2
|
-
|
|
3
|
-
// создаёт проверку origin для заданных protocol/domain/port
|
|
4
|
-
// (используется и игровым сервером, и мастер-сервером)
|
|
5
|
-
const createOriginValidator = function ({ protocol, domain, port }) {
|
|
6
|
-
return function (requestOrigin, cb) {
|
|
7
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
8
|
-
|
|
9
|
-
const allowedOrigins = [
|
|
10
|
-
// origin'ы для локальной разработки
|
|
11
|
-
`${protocol}//localhost:${port}`,
|
|
12
|
-
`${protocol}//127.0.0.1:${port}`,
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
if (isProduction) {
|
|
16
|
-
// в продакшене разрешен только настоящий домен по стандартному HTTPS
|
|
17
|
-
// порт не требуется, так как браузер не добавляет его
|
|
18
|
-
// для стандартного порта 443
|
|
19
|
-
allowedOrigins.push(`https://${domain}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
let err = null;
|
|
23
|
-
|
|
24
|
-
if (!allowedOrigins.includes(requestOrigin)) {
|
|
25
|
-
err = `Blocked connection from invalid origin: ${requestOrigin}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
process.nextTick(() => cb(err));
|
|
29
|
-
};
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const origin = function (requestOrigin, cb) {
|
|
33
|
-
const validator = createOriginValidator({
|
|
34
|
-
protocol: config.get('server:protocol'),
|
|
35
|
-
domain: config.get('server:domain'),
|
|
36
|
-
port: config.get('server:port'),
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
validator(requestOrigin, cb);
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export default {
|
|
43
|
-
origin,
|
|
44
|
-
createOriginValidator,
|
|
45
|
-
};
|