vimp-engine 0.37.1 → 0.38.1

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/client/boot.js +1 -1
  3. package/src/client/components/controller/ClientReports.js +2 -1
  4. package/src/client/components/controller/Games.js +1 -1
  5. package/src/client/components/controller/LobbyAuth.js +101 -15
  6. package/src/client/components/controller/Profile.js +95 -0
  7. package/src/client/components/model/Games.js +1 -1
  8. package/src/client/components/model/LobbyAuth.js +382 -115
  9. package/src/client/components/model/Profile.js +144 -0
  10. package/src/client/components/view/LobbyAuth.js +150 -62
  11. package/src/client/components/view/Profile.js +372 -0
  12. package/src/client/lib/authApi.js +106 -0
  13. package/src/client/lib/hostCaps.js +3 -2
  14. package/src/client/lib/pkce.js +23 -0
  15. package/src/client/lib/roomLink.js +1 -1
  16. package/src/client/lib/sessionSync.js +45 -0
  17. package/src/client/lib/signalingErrors.js +3 -3
  18. package/src/client/lib/turnstile.js +130 -0
  19. package/src/client/main.js +132 -26
  20. package/src/client/network/TokenRenewer.js +99 -0
  21. package/src/client/session/GuestSession.js +63 -17
  22. package/src/client/session/Membership.js +2 -1
  23. package/src/client/style.css +128 -16
  24. package/src/client/views/includes/lobby.pug +11 -6
  25. package/src/client/views/includes/lobbyAuth.pug +34 -16
  26. package/src/client/views/includes/profile.pug +73 -0
  27. package/src/client/views/index.pug +1 -0
  28. package/src/config/authClient.js +55 -30
  29. package/src/config/chatMessages.js +1 -2
  30. package/src/config/env.js +2 -2
  31. package/src/config/lobby.js +45 -1
  32. package/src/config/master.js +8 -5
  33. package/src/config/wsports.js +4 -1
  34. package/src/devtools/builtinScenario.js +1 -1
  35. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  36. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  37. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  38. package/src/devtools/invariants.js +1 -1
  39. package/src/devtools/pluginLoader.js +1 -1
  40. package/src/host/GameCoreAdapter.js +0 -3
  41. package/src/host/HostGame.js +28 -0
  42. package/src/host/PortMachine.js +50 -1
  43. package/src/host/meta/SocketManager.js +0 -13
  44. package/src/host/meta/core/RoundManager.js +0 -19
  45. package/src/host/meta/modules/PlayerDataSync.js +29 -3
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -4
  47. package/src/lib/loadGamePackage.js +1 -1
  48. package/src/lib/validators.js +8 -0
  49. package/src/standalone/index.js +1 -1
@@ -1,17 +1,45 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { decodeJwtPayload } from '../../../lib/jwt.js';
3
+ import { isValidNick } from '../../../lib/validators.js';
4
+ import { isSessionRejected } from '../../lib/authApi.js';
3
5
 
4
6
  // Singleton LobbyAuthModel
5
7
 
6
8
  let lobbyAuthModel;
7
9
 
8
- // Модель логина лобби (Этап B2): OAuth-редирект на central auth-сервис
9
- // (packages/auth), выбор ника при первом входе, identity JWT в localStorage.
10
- // Хранит состояние сессии и делает единственный сетевой вызов сама (POST
11
- // /nick) — в отличие от Lobby/Auth-моделей это простой fetch без сигнального
12
- // сокета, отдельный REST-канал к другому домену.
10
+ const SESSION_RE = /^s_[A-Za-z0-9_-]{43}$/;
11
+
12
+ // Модель профиля лобби: регистрация ника, сессия устройства, identity-токен
13
+ // (в памяти вкладки), возврат с OAuth (Restore и привязка). Сетевые вызовы —
14
+ // через инъектированный authApi, PKCE — через pkce.
15
+ //
16
+ // События: loading, welcome { notice }, authenticated { nick, role, profile },
17
+ // register-error <код>, restore-error <код>, service-error,
18
+ // token-renewed { expiresAt }, session-lost, profile-changed <profile>.
13
19
  export default class LobbyAuthModel {
14
- constructor(config) {
20
+ /**
21
+ * @param {Object} config - config/authClient.js.
22
+ * @param {Object} deps
23
+ * @param {Object} deps.api - client/lib/authApi.js.
24
+ * @param {Object} deps.pkce - { createVerifier, challengeOf }.
25
+ * @param {Storage} [deps.storage] - секрет сессии.
26
+ * @param {Storage} [deps.sessionStore] - попытка OAuth, уведомление.
27
+ * @param {boolean} [deps.isDev] - принимать ?devSession= (dev-сборка).
28
+ * @param {Function} [deps.getLocation]
29
+ * @param {Function} [deps.now] - () → мс эпохи по часам устройства.
30
+ */
31
+ constructor(
32
+ config,
33
+ {
34
+ api,
35
+ pkce,
36
+ storage = localStorage,
37
+ sessionStore = sessionStorage,
38
+ isDev = false,
39
+ getLocation = () => window.location,
40
+ now = () => Date.now(),
41
+ } = {},
42
+ ) {
15
43
  if (lobbyAuthModel) {
16
44
  return lobbyAuthModel;
17
45
  }
@@ -19,167 +47,406 @@ export default class LobbyAuthModel {
19
47
  lobbyAuthModel = this;
20
48
 
21
49
  this._config = config;
22
- this._pendingToken = null;
23
- this._identityToken = null;
24
- this._nick = null;
50
+ this._api = api;
51
+ this._pkce = pkce;
52
+ this._storage = storage;
53
+ this._sessionStore = sessionStore;
54
+ this._isDev = isDev;
55
+ this._getLocation = getLocation;
56
+ this._now = now;
57
+
58
+ this._session = null;
59
+ this._token = null;
60
+ this._tokenExpiresAt = null;
61
+ this._profile = null;
62
+ this._clientConfig = null;
63
+ this._urlParams = { code: null, error: null, devSession: null };
64
+ this._linkResult = null;
65
+ this._refreshing = null;
25
66
 
26
67
  this.publisher = new Publisher();
27
68
  }
28
69
 
29
- // разбирает query-параметры OAuth-редиректа (?token=/?pendingToken=/
30
- // ?authError=); при их отсутствии — восстанавливает сессию из localStorage.
31
- // Возвращает true, если параметры были найдены и вызывающий должен
32
- // подчистить адресную строку (history.replaceState)
33
- boot(search) {
34
- const { queryParams, tokenStorageKey } = this._config;
70
+ // читает параметры возврата с OAuth и (в dev-сборке) dev-сессию; true —
71
+ // что-то найдено, вызывающий чистит адресную строку. Ищет только в search:
72
+ // колбэк auth собирает адрес через URL.searchParams, поэтому параметры
73
+ // стоят до #, а hash — маршрут #/<gameId>[/<roomId>] (его разбирает RouteBoot)
74
+ takeUrlParams(search) {
75
+ const { oauth, devSessionParam } = this._config;
35
76
  const params = new URLSearchParams(search);
36
77
 
37
- const error = params.get(queryParams.error);
38
- const token = params.get(queryParams.token);
39
- const pendingToken = params.get(queryParams.pendingToken);
78
+ this._urlParams = {
79
+ code: params.get(oauth.codeParam),
80
+ error: params.get(oauth.errorParam),
81
+ devSession: this._isDev ? params.get(devSessionParam) : null,
82
+ };
83
+
84
+ const { code, error, devSession } = this._urlParams;
85
+
86
+ return Boolean(code || error || devSession);
87
+ }
40
88
 
41
- if (error) {
42
- this.publisher.emit('login-error', error);
43
- this._restore(tokenStorageKey);
44
- return true;
89
+ async boot() {
90
+ try {
91
+ await this._boot();
92
+ } catch {
93
+ // иначе экран навсегда остался бы на «Loading…» без Retry
94
+ this.publisher.emit('service-error');
45
95
  }
96
+ }
46
97
 
47
- if (token) {
48
- this._setIdentity(token, tokenStorageKey);
49
- return true;
98
+ async _boot() {
99
+ const { sessionStorageKey } = this._config;
100
+ const { devSession } = this._urlParams;
101
+
102
+ if (devSession && SESSION_RE.test(devSession)) {
103
+ this._storage.setItem(sessionStorageKey, devSession);
104
+ }
105
+
106
+ this.publisher.emit('loading');
107
+
108
+ // секрет читается до сети: настройки и токен уходят одновременно, лобби
109
+ // вернувшегося игрока открывается на круг запроса раньше
110
+ const stored = this._storage.getItem(sessionStorageKey);
111
+ // значение не того формата секретом не является: на auth не уходит
112
+ const session = stored && SESSION_RE.test(stored) ? stored : null;
113
+ const [cfg, res] = await Promise.all([
114
+ this._api.clientConfig(),
115
+ session ? this._api.token(session) : null,
116
+ ]);
117
+
118
+ if (!cfg.ok) {
119
+ this.publisher.emit('service-error');
120
+ return;
50
121
  }
51
122
 
52
- if (pendingToken) {
53
- this._pendingToken = pendingToken;
54
- this.publisher.emit('nick-required');
55
- return true;
123
+ this._clientConfig = cfg.data;
124
+
125
+ // попытка живёт один возврат: любой исход её закрывает
126
+ const attempt = this._takeAttempt();
127
+ const { code, error } = this._urlParams;
128
+
129
+ this._urlParams = { code: null, error: null, devSession: null };
130
+
131
+ if (!session) {
132
+ await this._bootWithoutSession(attempt, code, error);
133
+ return;
56
134
  }
57
135
 
58
- this._restore(tokenStorageKey);
59
- return false;
136
+ this._session = session;
137
+
138
+ if (res.ok) {
139
+ this._adopt({ token: res.data.token, profile: res.data.profile });
140
+ // уведомление писала вкладка, потерявшая сессию в комнате; под принятой
141
+ // личностью оно ни к чему и не должно всплыть на Welcome позже
142
+ this._takeNotice();
143
+ await this._finishLink(attempt, code, error);
144
+ this._emitAuthenticated();
145
+ } else if (isSessionRejected(res)) {
146
+ this._forgetSession();
147
+ this.publisher.emit('welcome', { notice: 'profileGone' });
148
+ } else {
149
+ // код OAuth одноразовый: Retry доведёт привязку, а не потеряет её
150
+ this._restoreAttempt(attempt, code, error);
151
+ this.publisher.emit('service-error');
152
+ }
60
153
  }
61
154
 
62
- // URL редиректа на старт OAuth-потока auth-сервиса; вызывающий должен сам
63
- // сделать переход (window.location.href) — модель не трогает location.
64
- // hash (#/<gameId>[/<roomId>]) едет в returnUrl: после логина игрок
65
- // попадает туда, куда вёл открытый URL, а не в общее лобби
66
- loginUrl(provider) {
67
- const { origin, pathname, hash } = window.location;
68
- const returnUrl = `${origin}${pathname}${hash}`;
155
+ async _bootWithoutSession(attempt, code, error) {
156
+ const notice = this._takeNotice();
157
+ const restoring = attempt?.mode === 'restore';
158
+
159
+ if (restoring && code) {
160
+ const res = await this._api.restore({
161
+ code,
162
+ verifier: attempt.verifier,
163
+ termsVersion: this._clientConfig.legal.version,
164
+ });
165
+
166
+ if (res.ok) {
167
+ this._adopt(res.data);
168
+ this._emitAuthenticated();
169
+ return;
170
+ }
171
+
172
+ this.publisher.emit('welcome', { notice });
173
+ this.publisher.emit('restore-error', res.error);
174
+ return;
175
+ }
176
+
177
+ this.publisher.emit('welcome', { notice });
69
178
 
70
- return `${this._config.serviceUrl}/oauth/${provider}/start?returnUrl=${encodeURIComponent(returnUrl)}`;
179
+ if (restoring && error) {
180
+ this.publisher.emit(
181
+ 'restore-error',
182
+ error === 'denied' ? 'denied' : 'failed',
183
+ );
184
+ }
71
185
  }
72
186
 
73
- async submitNick(nick) {
74
- if (!this._pendingToken) {
75
- this.publisher.emit('nick-error', 'sessionExpired');
187
+ // привязка завершается до authenticated, а не в панели профиля: код живёт
188
+ // 120 с и не должен ждать, пока откроется лобби
189
+ async _finishLink(attempt, code, error) {
190
+ if (attempt?.mode !== 'link') {
76
191
  return;
77
192
  }
78
193
 
79
- let res;
80
- let data;
194
+ const { provider } = attempt;
81
195
 
82
- try {
83
- res = await fetch(`${this._config.serviceUrl}/nick`, {
84
- method: 'POST',
85
- headers: {
86
- 'Content-Type': 'application/json',
87
- Authorization: `Bearer ${this._pendingToken}`,
88
- },
89
- body: JSON.stringify({ nick }),
196
+ if (code) {
197
+ const res = await this._api.linkOauth(this._session, {
198
+ code,
199
+ verifier: attempt.verifier,
200
+ consentVersion: attempt.consentVersion,
90
201
  });
91
- data = await res.json().catch(() => ({}));
202
+
203
+ if (res.ok) {
204
+ this._profile = res.data.profile;
205
+ this._linkResult = { ok: true, provider };
206
+ } else {
207
+ this._linkResult = { ok: false, provider, code: res.error };
208
+ }
209
+ } else if (error) {
210
+ this._linkResult = {
211
+ ok: false,
212
+ provider,
213
+ code: error === 'denied' ? 'denied' : 'failed',
214
+ };
215
+ }
216
+ }
217
+
218
+ _restoreAttempt(attempt, code, error) {
219
+ if (attempt) {
220
+ this._sessionStore.setItem(
221
+ this._config.oauth.pendingKey,
222
+ JSON.stringify(attempt),
223
+ );
224
+ }
225
+
226
+ this._urlParams = { code, error, devSession: null };
227
+ }
228
+
229
+ _takeAttempt() {
230
+ const key = this._config.oauth.pendingKey;
231
+ const raw = this._sessionStore.getItem(key);
232
+
233
+ this._sessionStore.removeItem(key);
234
+
235
+ try {
236
+ return raw ? JSON.parse(raw) : null;
92
237
  } catch {
93
- this.publisher.emit('nick-error', 'network');
94
- return;
238
+ return null;
95
239
  }
240
+ }
96
241
 
97
- if (!res.ok) {
98
- this.publisher.emit('nick-error', data.error || 'unknown');
99
- return;
242
+ _takeNotice() {
243
+ const key = this._config.noticeKey;
244
+ const notice = this._sessionStore.getItem(key);
245
+
246
+ this._sessionStore.removeItem(key);
247
+
248
+ return notice || null;
249
+ }
250
+
251
+ // принять личность: секрет сессии (если пришёл) — в хранилище, токен и
252
+ // профиль — в память
253
+ _adopt({ session, token, profile }) {
254
+ if (session) {
255
+ this._session = session;
256
+ this._storage.setItem(this._config.sessionStorageKey, session);
257
+ }
258
+
259
+ this._setToken(token);
260
+ this._profile = profile;
261
+ }
262
+
263
+ _emitAuthenticated() {
264
+ this.publisher.emit('authenticated', {
265
+ nick: this.getNick(),
266
+ role: this.getRole(),
267
+ profile: this._profile,
268
+ });
269
+ }
270
+
271
+ _forgetSession() {
272
+ const key = this._config.sessionStorageKey;
273
+
274
+ // в хранилище может лежать секрет ДРУГОГО профиля: его записала вкладка,
275
+ // сменившая личность браузера, пока эта доигрывала матч под прежней.
276
+ // Стирается только свой — у профиля без привязки он единственный ключ
277
+ if (this._storage.getItem(key) === this._session) {
278
+ this._storage.removeItem(key);
100
279
  }
101
280
 
102
- this._pendingToken = null;
103
- this._setIdentity(data.token, this._config.tokenStorageKey);
281
+ this._session = null;
282
+ this._token = null;
283
+ this._tokenExpiresAt = null;
284
+ this._profile = null;
104
285
  }
105
286
 
106
- logout() {
107
- delete localStorage[this._config.tokenStorageKey];
287
+ validateNick(nick) {
288
+ return isValidNick(nick);
289
+ }
108
290
 
109
- this._identityToken = null;
110
- this._nick = null;
291
+ async register(nick, turnstileToken) {
292
+ const res = await this._api.register({
293
+ nick,
294
+ turnstileToken,
295
+ termsVersion: this._clientConfig.legal.version,
296
+ });
111
297
 
112
- this.publisher.emit('login-required', this._config.providers);
298
+ if (!res.ok) {
299
+ this.publisher.emit('register-error', res.error);
300
+ return false;
301
+ }
302
+
303
+ this._adopt(res.data);
304
+ this._emitAuthenticated();
305
+
306
+ return true;
113
307
  }
114
308
 
115
- getToken() {
116
- return this._identityToken;
309
+ // URL старта OAuth для Restore; попытка (verifier) остаётся в
310
+ // sessionStorage вкладки, на auth уходит только challenge
311
+ async restoreUrl(provider) {
312
+ return this._oauthUrl('restore', provider);
117
313
  }
118
314
 
119
- getNick() {
120
- return this._nick;
315
+ // то же для привязки из панели профиля; consentVersion — версия
316
+ // согласий, принятых чекбоксами перед переходом
317
+ async linkUrl(provider, consentVersion) {
318
+ return this._oauthUrl('link', provider, { consentVersion });
121
319
  }
122
320
 
123
- // роль из payload уже проверенного на срок токена (master-game-registry,
124
- // этап 4). Это ПОДСКАЗКА ИНТЕРФЕЙСУ — показывать ли кнопку модерации, — а
125
- // не право: доступ к данным проверяет мастер по подписи, а запись —
126
- // auth-сервис, перечитывая роль из БД
127
- getRole() {
128
- return decodeJwtPayload(this._identityToken)?.role ?? 'user';
321
+ async _oauthUrl(mode, provider, extra = {}) {
322
+ const verifier = this._pkce.createVerifier();
323
+ const challenge = await this._pkce.challengeOf(verifier);
324
+ const { origin, pathname, hash } = this._getLocation();
325
+
326
+ this._sessionStore.setItem(
327
+ this._config.oauth.pendingKey,
328
+ JSON.stringify({ mode, provider, verifier, ...extra }),
329
+ );
330
+
331
+ // hash (#/<gameId>[/<roomId>]) едет в returnUrl: после возврата игрок
332
+ // попадает туда, куда вёл открытый адрес
333
+ return this._api.oauthStartUrl(provider, {
334
+ mode,
335
+ returnUrl: `${origin}${pathname}${hash}`,
336
+ challenge,
337
+ });
129
338
  }
130
339
 
131
- // срок входа (мс эпохи) или null — неизвестен. Вкладка с кончающимся
132
- // токеном не принимает роль хоста: register_host/reclaim_host предъявят
133
- // его мастеру посреди матча (host-migration-review этап 7)
134
- getTokenExpiresAt() {
135
- const exp = decodeJwtPayload(this._identityToken)?.exp;
340
+ // продление identity-токена по сессии; параллельные вызовы делят запрос
341
+ refresh() {
342
+ if (!this._refreshing) {
343
+ this._refreshing = this._refresh().finally(() => {
344
+ this._refreshing = null;
345
+ });
346
+ }
136
347
 
137
- return typeof exp === 'number' ? exp * 1000 : null;
348
+ return this._refreshing;
138
349
  }
139
350
 
140
- _restore(tokenStorageKey) {
141
- const token = localStorage[tokenStorageKey];
351
+ async _refresh() {
352
+ if (!this._session) {
353
+ return 'lost';
354
+ }
142
355
 
143
- if (token) {
144
- this._setIdentity(token, tokenStorageKey, false);
145
- } else {
146
- this.publisher.emit('login-required', this._config.providers);
356
+ const res = await this._api.token(this._session);
357
+
358
+ if (res.ok) {
359
+ this._setToken(res.data.token);
360
+ this._profile = res.data.profile;
361
+ this.publisher.emit('token-renewed', {
362
+ expiresAt: this.getTokenExpiresAt(),
363
+ });
364
+ return 'ok';
147
365
  }
366
+
367
+ if (isSessionRejected(res)) {
368
+ this._forgetSession();
369
+ this.publisher.emit('session-lost');
370
+ return 'lost';
371
+ }
372
+
373
+ return 'retry';
374
+ }
375
+
376
+ // итог привязки, завершённой в boot(); отдаётся один раз
377
+ takeLinkResult() {
378
+ const result = this._linkResult;
379
+
380
+ this._linkResult = null;
381
+
382
+ return result;
148
383
  }
149
384
 
150
- // payload JWT не проверяется подписью на клиенте (только для отображения
151
- // ника) — авторитетная проверка по /jwks на хосте (Этап B3). exp
152
- // проверяется (F5 кодревью) — иначе восстановленная из localStorage сессия
153
- // показывает «залогинен» токеном, который хост уже отклонит при входе в игру
154
- _setIdentity(token, tokenStorageKey, persist = true) {
385
+ setProfile(profile) {
386
+ this._profile = profile;
387
+ this.publisher.emit('profile-changed', profile);
388
+ }
389
+
390
+ // выход и удаление профиля (панель профиля)
391
+ clearSession() {
392
+ this._forgetSession();
393
+ }
394
+
395
+ // уведомление для экрана Welcome после перезагрузки страницы
396
+ setNotice(code) {
397
+ this._sessionStore.setItem(this._config.noticeKey, code);
398
+ }
399
+
400
+ getToken() {
401
+ return this._token;
402
+ }
403
+
404
+ getNick() {
405
+ return this._profile?.nick ?? null;
406
+ }
407
+
408
+ // роль из payload токена — ПОДСКАЗКА ИНТЕРФЕЙСУ (показывать ли кнопку
409
+ // модерации), не право: права админа auth-сервис проверяет сам, по нику
410
+ // из VIMP_ADMIN_NICKS
411
+ getRole() {
412
+ return decodeJwtPayload(this._token)?.role ?? 'user';
413
+ }
414
+
415
+ // Срок токена в шкале часов УСТРОЙСТВА: момент получения плюс время жизни
416
+ // из самого токена (exp − iat — оба выставлены часами auth-сервиса).
417
+ // Сдвиг часов устройства на срок не влияет. Токен без iat — срок по exp
418
+ _setToken(token) {
155
419
  const payload = decodeJwtPayload(token);
156
- const isExpired =
157
- typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
158
-
159
- if (!payload || !payload.nick || isExpired) {
160
- delete localStorage[tokenStorageKey];
161
-
162
- // Тихое восстановление из localStorage (persist === false): просроченный/
163
- // битый токен — штатная ситуация возврата на сайт, не ошибка. login-error
164
- // — только на интерактивном пути.
165
- if (persist) {
166
- this.publisher.emit(
167
- 'login-error',
168
- isExpired ? 'tokenExpired' : 'invalidToken',
169
- );
170
- }
420
+ const exp = payload?.exp;
421
+ const iat = payload?.iat;
171
422
 
172
- this.publisher.emit('login-required', this._config.providers);
173
- return;
423
+ this._token = token;
424
+
425
+ if (typeof exp !== 'number') {
426
+ this._tokenExpiresAt = null;
427
+ } else if (typeof iat === 'number' && exp > iat) {
428
+ this._tokenExpiresAt = this._now() + (exp - iat) * 1000;
429
+ } else {
430
+ this._tokenExpiresAt = exp * 1000;
174
431
  }
432
+ }
175
433
 
176
- this._identityToken = token;
177
- this._nick = payload.nick;
434
+ // срок входа (мс эпохи по часам устройства) или null — неизвестен.
435
+ // Вкладка с кончающимся токеном не принимает роль хоста:
436
+ // register_host/reclaim_host предъявят его мастеру посреди матча
437
+ getTokenExpiresAt() {
438
+ return this._tokenExpiresAt;
439
+ }
178
440
 
179
- if (persist) {
180
- localStorage[tokenStorageKey] = token;
181
- }
441
+ getProfile() {
442
+ return this._profile;
443
+ }
444
+
445
+ getClientConfig() {
446
+ return this._clientConfig;
447
+ }
182
448
 
183
- this.publisher.emit('authenticated', { nick: this._nick });
449
+ getSession() {
450
+ return this._session;
184
451
  }
185
452
  }