Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d87c0fb10b | |||
| 84ecc85f51 |
@@ -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
@@ -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 &
|
||||||
|
|||||||
@@ -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, а также
|
||||||
|
|||||||
@@ -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;
|
return;
|
||||||
}
|
}
|
||||||
// A connectivity failure is shown by the "Connecting…" header indicator (and auto-retried),
|
if (isConnectionCode(code) || !connection.online) return;
|
||||||
// not a red toast on every attempt.
|
telegramHaptic('error');
|
||||||
if (isConnectionCode(err.code)) return;
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
showToast(t(errorKey(err.code)), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showToast(t('error.generic'), 'error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStream(): void {
|
function openStream(): void {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user