Compare commits

...

2 Commits

Author SHA1 Message Date
Ilia Denisov d87c0fb10b Stage 17: cap display-name special characters at 5 (ui + backend)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
display_name validation gains a rule: at most 5 special characters — the '.' / '_'
punctuation (spaces, which separate words, don't count) — so a still-well-formed name
can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI
validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs:
FUNCTIONAL (+ _ru).
2026-06-09 07:42:47 +02:00
Ilia Denisov 84ecc85f51 Stage 17 #2 fix: connection failures show only the spinner, never a toast
A dropped/reset/timed-out connection can surface as a Connect code other than
Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic
'internal' -> a red 'something went wrong' toast appeared alongside the Connecting
spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every
transport-level code to 'unavailable' so it is retried + flips offline; and handleError
suppresses the toast for any connection code AND whenever the app is mid-reconnect
(!connection.online), covering the race where a unary error lands before the stream
reports the drop. Genuine server-internal / domain errors still toast while online.
2026-06-09 07:42:47 +02:00
10 changed files with 102 additions and 40 deletions
+14
View File
@@ -23,6 +23,11 @@ import (
// is unbounded; auto-provisioned platform names bypass this editor validation). // is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32 const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour const maxAwayWindow = 12 * time.Hour
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
if !displayNameRe.MatchString(name) { if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile) return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
} }
specials := 0
for _, r := range name {
if r != ' ' && !unicode.IsLetter(r) {
specials++
}
}
if specials > maxDisplayNameSpecials {
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
}
return name, nil return name, nil
} }
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
"digit rejected": {"Name2", "", false}, "digit rejected": {"Name2", "", false},
"blank": {" ", "", false}, "blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false}, "too long": {strings.Repeat("a", 33), "", false},
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
} }
for name, tc := range cases { for name, tc := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
+2 -1
View File
@@ -141,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is
### Profile & settings *(Stage 4 / 8)* ### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by a single space / "." / "_" separator, with an Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking & an email or Telegram and merging accounts are covered under "Accounts, linking &
+2 -1
View File
@@ -146,7 +146,8 @@ push доставляется через платформу.
### Профиль и настройки *(Stage 4 / 8)* ### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от «_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
+10 -13
View File
@@ -23,7 +23,7 @@ import {
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry'; import { isConnectionCode } from './retry';
import { clearGameCache } from './gamecache'; import { clearGameCache } from './gamecache';
import { clearLobby } from './lobbycache'; import { clearLobby } from './lobbycache';
@@ -115,21 +115,18 @@ export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
} }
/** handleError maps a GatewayError to a toast; an invalid session logs out. */ /** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
* header indicator (and auto-retried), never a red toast. */
export function handleError(err: unknown): void { export function handleError(err: unknown): void {
telegramHaptic('error'); const code = err instanceof GatewayError ? err.code : '';
if (err instanceof GatewayError) { if (code === 'session_invalid' || code === 'unauthenticated') {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') { void logout();
void logout();
return;
}
// A connectivity failure is shown by the "Connecting…" header indicator (and auto-retried),
// not a red toast on every attempt.
if (isConnectionCode(err.code)) return;
showToast(t(errorKey(err.code)), 'error');
return; return;
} }
showToast(t('error.generic'), 'error'); if (isConnectionCode(code) || !connection.online) return;
telegramHaptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
} }
function openStream(): void { function openStream(): void {
+3
View File
@@ -19,6 +19,9 @@ describe('validDisplayName', () => {
['Name2', false], ['Name2', false],
['', false], ['', false],
['a'.repeat(33), false], ['a'.repeat(33), false],
['a.a.a.a.a.a', true], // 5 dots — at the special-char limit
['a.a.a.a.a.a.a', false], // 6 dots — over the limit
['J. R. R. Tolkien', true], // 3 dots; spaces are not special
])('%s -> %s', (name, ok) => { ])('%s -> %s', (name, ok) => {
expect(validDisplayName(name)).toBe(ok); expect(validDisplayName(name)).toBe(ok);
}); });
+8 -1
View File
@@ -5,6 +5,10 @@
/** maxDisplayName caps the editable display name in runes. */ /** maxDisplayName caps the editable display name in runes. */
export const maxDisplayName = 32; export const maxDisplayName = 32;
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
export const maxDisplayNameSpecials = 5;
/** maxAwayMinutes bounds the daily away window's length (12 h). */ /** maxAwayMinutes bounds the daily away window's length (12 h). */
export const maxAwayMinutes = 12 * 60; export const maxAwayMinutes = 12 * 60;
@@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */ /** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean { export function validDisplayName(raw: string): boolean {
const name = raw.trim(); const name = raw.trim();
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name); const chars = [...name];
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
return specials <= maxDisplayNameSpecials;
} }
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces // A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
+23 -1
View File
@@ -1,5 +1,27 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { backoffMs, isConnectionCode, retryable } from './retry'; import { Code, ConnectError } from '@connectrpc/connect';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
describe('toGatewayError', () => {
it('collapses every transport-level failure to a connection code (so it is retried + suppressed)', () => {
for (const c of [Code.Unavailable, Code.DeadlineExceeded, Code.Canceled, Code.Aborted, Code.Unknown]) {
const e = toGatewayError(new ConnectError('x', c));
expect(e.code).toBe('unavailable');
expect(isConnectionCode(e.code)).toBe(true);
}
});
it('treats a raw (non-Connect) network error as a connection failure', () => {
expect(toGatewayError(new TypeError('Failed to fetch')).code).toBe('unavailable');
});
it('preserves rate-limit, an invalid session, and a genuine server-internal error', () => {
expect(toGatewayError(new ConnectError('x', Code.ResourceExhausted)).code).toBe('rate_limited');
expect(toGatewayError(new ConnectError('x', Code.Unauthenticated)).code).toBe('session_invalid');
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
expect(isConnectionCode('internal')).toBe(false);
});
});
describe('retryable', () => { describe('retryable', () => {
it('retries any op on a rate-limit rejection (it never reached the backend)', () => { it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
+35 -3
View File
@@ -1,6 +1,6 @@
// Retry policy for the gateway transport (Stage 17). When a unary call fails at the transport // Retry policy + error classification for the gateway transport (Stage 17). When a unary call
// level the app retries it with capped exponential backoff while showing the "Connecting…" // fails at the transport level the app retries it with capped exponential backoff while showing
// indicator, instead of flashing a red toast each time. // the "Connecting…" indicator, instead of flashing a red toast each time.
// //
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is // Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have // safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
@@ -8,6 +8,38 @@
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and // 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
// re-enables on reconnect, so the player re-issues it deliberately). // re-enables on reconnect, so the player re-issues it deliberately).
import { Code, ConnectError } from '@connectrpc/connect';
import { GatewayError } from './client';
/**
* toGatewayError normalises a thrown Connect/transport error to a GatewayError with a stable code.
* Connection-level failures — the server is unreachable, the request timed out, was reset or
* cancelled, or a raw network error — all collapse to **'unavailable'**, so they are handled as
* connectivity (the indicator + retry), never as a red error toast. A genuine server-side
* 'internal' or a domain code is preserved.
*/
export function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
switch (e.code) {
case Code.Unauthenticated:
return new GatewayError('session_invalid', e.message);
case Code.ResourceExhausted:
return new GatewayError('rate_limited', e.message);
case Code.Unavailable:
case Code.DeadlineExceeded:
case Code.Canceled:
case Code.Aborted:
case Code.Unknown:
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
default:
return new GatewayError('internal', e.message);
}
}
return new GatewayError('unavailable', String(e));
}
/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */ /** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */
export const READ_OPS: ReadonlySet<string> = new Set([ export const READ_OPS: ReadonlySet<string> = new Set([
'profile.get', 'profile.get',
+2 -20
View File
@@ -5,35 +5,17 @@
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c // a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin. // gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
import { Code, ConnectError, createClient } from '@connectrpc/connect'; import { createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web'; import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb'; import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client'; import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable } from './retry'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
const MAX_RETRIES = 6; const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function toGatewayError(e: unknown): GatewayError {
if (e instanceof ConnectError) {
switch (e.code) {
case Code.Unauthenticated:
return new GatewayError('session_invalid', e.message);
case Code.ResourceExhausted:
return new GatewayError('rate_limited', e.message);
case Code.Unavailable:
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
default:
return new GatewayError('internal', e.message);
}
}
return new GatewayError('unavailable', String(e));
}
export function createTransport(baseUrl: string): GatewayClient { export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });