From 06d5e3fbf31ad5ec93e77ed7c246ab8463bf2dff Mon Sep 17 00:00:00 2001 From: Jage9 Date: Tue, 24 Feb 2026 22:35:29 -0500 Subject: [PATCH] Show server auth policy limits in client auth UI --- client/index.html | 2 + client/public/version.js | 2 +- client/src/main.ts | 82 +++++++++++++++++++++++++-- client/src/network/messageHandlers.ts | 4 +- client/src/network/protocol.ts | 24 ++++++++ client/src/styles.css | 6 ++ docs/protocol-notes.md | 3 + docs/runtime-flow.md | 3 +- server/app/models.py | 2 + server/app/server.py | 39 +++++++++++-- 10 files changed, 154 insertions(+), 13 deletions(-) diff --git a/client/index.html b/client/index.html index 1d1f17b..5f94043 100644 --- a/client/index.html +++ b/client/index.html @@ -19,6 +19,7 @@ +

diff --git a/client/public/version.js b/client/public/version.js index d0fe5a5..5c37a6e 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.25 R244"; +window.CHGRID_WEB_VERSION = "2026.02.25 R245"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index d6143fa..a45587d 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -79,6 +79,7 @@ const RECONNECT_MAX_ATTEMPTS = 3; const AUDIO_SUBSCRIPTION_REFRESH_MS = 500; const TELEPORT_SQUARES_PER_SECOND = 20; const TELEPORT_SYNC_INTERVAL_MS = 100; +const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy'; declare global { interface Window { @@ -94,9 +95,11 @@ type Dom = { registerView: HTMLElement; authUsername: HTMLInputElement; authPassword: HTMLInputElement; + authPolicyHintLogin: HTMLParagraphElement; registerUsername: HTMLInputElement; registerPassword: HTMLInputElement; registerEmail: HTMLInputElement; + authPolicyHintRegister: HTMLParagraphElement; showRegisterButton: HTMLButtonElement; showLoginButton: HTMLButtonElement; updatesSection: HTMLElement; @@ -125,9 +128,11 @@ const dom: Dom = { registerView: requiredById('registerView'), authUsername: requiredById('authUsername'), authPassword: requiredById('authPassword'), + authPolicyHintLogin: requiredById('authPolicyHintLogin'), registerUsername: requiredById('registerUsername'), registerPassword: requiredById('registerPassword'), registerEmail: requiredById('registerEmail'), + authPolicyHintRegister: requiredById('authPolicyHintRegister'), showRegisterButton: requiredById('showRegisterButton'), showLoginButton: requiredById('showLoginButton'), updatesSection: requiredById('updatesSection'), @@ -172,6 +177,13 @@ type HelpData = { sections: HelpSection[]; }; +type AuthPolicy = { + usernameMinLength: number; + usernameMaxLength: number; + passwordMinLength: number; + passwordMaxLength: number; +}; + /** Builds linearized help-view lines from sectioned help content. */ function buildHelpLines(help: HelpData): string[] { const lines: string[] = []; @@ -222,6 +234,7 @@ let outputMode = settings.loadOutputMode(); let authMode: 'login' | 'register' = 'login'; let authSessionToken = settings.loadAuthSessionToken(); let authUsername = settings.loadAuthUsername(); +let authPolicy: AuthPolicy | null = null; let pendingAuthRequest = false; const messageBuffer: string[] = []; let messageCursor = -1; @@ -504,11 +517,63 @@ function sanitizeName(value: string): string { /** Normalizes auth username according to server policy. */ function sanitizeAuthUsername(value: string): string { + const maxLength = authPolicy?.usernameMaxLength ?? 128; return value .trim() .toLowerCase() .replace(/[^a-z0-9_-]/g, '') - .slice(0, 32); + .slice(0, Math.max(1, maxLength)); +} + +/** Normalizes and stores server-advertised auth policy limits, then refreshes auth UI hints. */ +function applyAuthPolicy(policy: unknown): void { + if (!policy || typeof policy !== 'object') return; + const raw = policy as Partial; + const usernameMin = Number(raw.usernameMinLength); + const usernameMax = Number(raw.usernameMaxLength); + const passwordMin = Number(raw.passwordMinLength); + const passwordMax = Number(raw.passwordMaxLength); + if ( + !Number.isInteger(usernameMin) || + !Number.isInteger(usernameMax) || + !Number.isInteger(passwordMin) || + !Number.isInteger(passwordMax) + ) { + return; + } + if (usernameMin < 1 || usernameMax < usernameMin || passwordMin < 1 || passwordMax < passwordMin) { + return; + } + authPolicy = { + usernameMinLength: usernameMin, + usernameMaxLength: usernameMax, + passwordMinLength: passwordMin, + passwordMaxLength: passwordMax, + }; + localStorage.setItem(AUTH_POLICY_STORAGE_KEY, JSON.stringify(authPolicy)); + const hint = `Username: ${usernameMin}-${usernameMax} chars (a-z, 0-9, _, -). Password: ${passwordMin}-${passwordMax} chars.`; + dom.authPolicyHintLogin.textContent = hint; + dom.authPolicyHintRegister.textContent = hint; + dom.authUsername.minLength = usernameMin; + dom.authUsername.maxLength = usernameMax; + dom.registerUsername.minLength = usernameMin; + dom.registerUsername.maxLength = usernameMax; + dom.authPassword.minLength = passwordMin; + dom.authPassword.maxLength = passwordMax; + dom.registerPassword.minLength = passwordMin; + dom.registerPassword.maxLength = passwordMax; + updateConnectAvailability(); +} + +/** Loads most recently-seen auth policy limits from local storage for pre-connect UI hints. */ +function loadPersistedAuthPolicy(): void { + const raw = localStorage.getItem(AUTH_POLICY_STORAGE_KEY); + if (!raw) return; + try { + applyAuthPolicy(JSON.parse(raw)); + } catch { + // Ignore malformed persisted policy and keep live server policy source of truth. + } } /** Enables/disables the connect button based on state and nickname validity. */ @@ -524,10 +589,13 @@ function updateConnectAvailability(): void { dom.loginView.classList.toggle('hidden', authMode !== 'login'); dom.registerView.classList.toggle('hidden', authMode !== 'register'); const hasSessionToken = authSessionToken.trim().length > 0; + const usernameMin = authPolicy?.usernameMinLength ?? 1; + const passwordMin = authPolicy?.passwordMinLength ?? 1; const hasLoginCredentials = - sanitizeAuthUsername(dom.authUsername.value).length >= 2 && dom.authPassword.value.trim().length >= 8; + sanitizeAuthUsername(dom.authUsername.value).length >= usernameMin && dom.authPassword.value.trim().length >= passwordMin; const hasRegisterCredentials = - sanitizeAuthUsername(dom.registerUsername.value).length >= 2 && dom.registerPassword.value.trim().length >= 8; + sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin && + dom.registerPassword.value.trim().length >= passwordMin; const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials); dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect'; dom.connectButton.disabled = mediaSession.isConnecting() || !authReady; @@ -1379,14 +1447,16 @@ function sendAuthRequest(): void { } /** Handles server auth-required prompts prior to world welcome. */ -function handleAuthRequired(message: string): void { +function handleAuthRequired(message: Extract): void { + applyAuthPolicy(message.authPolicy); setConnectionStatus('Authentication required.'); - updateStatus(message); + updateStatus(message.message); } /** Applies auth result state and terminates failed auth attempts quickly. */ async function handleAuthResult(message: Extract): Promise { pendingAuthRequest = false; + applyAuthPolicy(message.authPolicy); if (!message.ok) { dom.authPassword.value = ''; dom.registerPassword.value = ''; @@ -1581,6 +1651,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise { let restartAnnouncement: string | null = null; let connectedAnnouncement: string | null = null; if (message.type === 'welcome') { + applyAuthPolicy(message.auth?.policy); const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; connectedAnnouncement = reconnectInFlight @@ -2608,6 +2679,7 @@ setupInputHandlers(); setupUiHandlers(); dom.authUsername.value = sanitizeAuthUsername(authUsername); dom.registerUsername.value = sanitizeAuthUsername(authUsername); +loadPersistedAuthPolicy(); setAuthMode('login'); updateConnectAvailability(); updateDeviceSummary(); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index f0815c5..d315cad 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -70,7 +70,7 @@ type MessageHandlerDeps = { playLocateToneAt: (x: number, y: number) => void; resolveIncomingSoundUrl: (url: string) => string; playIncomingItemUseSound: (url: string, x: number, y: number) => void; - handleAuthRequired: (message: string) => void; + handleAuthRequired: (message: Extract) => void; handleAuthResult: (message: Extract) => Promise; }; @@ -81,7 +81,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco return async function onMessage(message: IncomingMessage): Promise { switch (message.type) { case 'auth_required': - deps.handleAuthRequired(message.message); + deps.handleAuthRequired(message); break; case 'auth_result': diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 1a1290f..d68f2bb 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -54,6 +54,14 @@ export const welcomeMessageSchema = z.object({ userId: z.string().nullable().optional(), username: z.string().nullable().optional(), role: z.string().nullable().optional(), + policy: z + .object({ + usernameMinLength: z.number().int().positive(), + usernameMaxLength: z.number().int().positive(), + passwordMinLength: z.number().int().positive(), + passwordMaxLength: z.number().int().positive(), + }) + .optional(), }) .optional(), uiDefinitions: z @@ -96,6 +104,14 @@ export const welcomeMessageSchema = z.object({ export const authRequiredSchema = z.object({ type: z.literal('auth_required'), message: z.string(), + authPolicy: z + .object({ + usernameMinLength: z.number().int().positive(), + usernameMaxLength: z.number().int().positive(), + passwordMinLength: z.number().int().positive(), + passwordMaxLength: z.number().int().positive(), + }) + .optional(), }); export const authResultSchema = z.object({ @@ -106,6 +122,14 @@ export const authResultSchema = z.object({ username: z.string().optional(), role: z.string().optional(), nickname: z.string().optional(), + authPolicy: z + .object({ + usernameMinLength: z.number().int().positive(), + usernameMaxLength: z.number().int().positive(), + passwordMinLength: z.number().int().positive(), + passwordMaxLength: z.number().int().positive(), + }) + .optional(), }); export const signalMessageSchema = z.object({ diff --git a/client/src/styles.css b/client/src/styles.css index 8993308..623bffd 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -117,6 +117,12 @@ body { width: min(280px, 60vw); } +.auth-hint { + color: #94a3b8; + margin: 0.35rem 0 0.5rem; + font-size: 0.92rem; +} + .nickname-row { display: flex; justify-content: center; diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 4da6cd6..d9e63fd 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -62,6 +62,9 @@ This is a behavior guide for packet semantics beyond raw schemas. - `userId` - `username` - `role` + - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) +- `auth_required.authPolicy`: server auth limits advertised before login/register submit. +- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses. - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. - `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing. - `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index a99d15e..88c34f7 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -6,6 +6,7 @@ 2. Client validates auth form/session token and sets up local media. 3. Client connects signaling websocket. 4. Server sends `auth_required`. + - includes `authPolicy` limits for username/password. 5. Client sends `auth_login`, `auth_register`, or `auth_resume`. 6. Server sends `auth_result`. 7. Server sends `welcome` with users/items snapshot. @@ -42,7 +43,7 @@ Core incoming message effects: - `signal`: WebRTC negotiation and ICE exchange. - `auth_required`: prompt client to authenticate before gameplay messages. -- `auth_result`: auth success/failure with optional session token + account metadata. +- `auth_result`: auth success/failure with optional session token + account metadata + `authPolicy`. - `update_position`: update peer position; may play movement/teleport world sound. - `teleport_complete`: play peer teleport landing sound at final tile. - `update_nickname`: update peer display name. diff --git a/server/app/models.py b/server/app/models.py index e651cbd..5bb0aa6 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -160,6 +160,7 @@ class WelcomePacket(BasePacket): class AuthRequiredPacket(BasePacket): type: Literal["auth_required"] message: str + authPolicy: dict | None = None class AuthResultPacket(BasePacket): @@ -170,6 +171,7 @@ class AuthResultPacket(BasePacket): username: str | None = None role: str | None = None nickname: str | None = None + authPolicy: dict | None = None class UserLeftPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index e0f684c..9273873 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -177,6 +177,16 @@ class SignalingServer: return nickname.casefold() + def _auth_policy(self) -> dict[str, int]: + """Return server-auth policy limits advertised to clients.""" + + return { + "usernameMinLength": self.auth_service.username_min_length, + "usernameMaxLength": self.auth_service.username_max_length, + "passwordMinLength": self.auth_service.password_min_length, + "passwordMaxLength": self.auth_service.password_max_length, + } + def _flush_state_save(self) -> None: """Immediately flush pending state persistence and clear debounce state.""" @@ -748,7 +758,11 @@ class SignalingServer: try: await self._send( websocket, - AuthRequiredPacket(type="auth_required", message="Authentication required."), + AuthRequiredPacket( + type="auth_required", + message="Authentication required.", + authPolicy=self._auth_policy(), + ), ) async for raw_message in websocket: await self._handle_message(client, raw_message) @@ -805,6 +819,7 @@ class SignalingServer: "userId": client.user_id, "username": client.username, "role": client.role if client.authenticated else None, + "policy": self._auth_policy(), }, ) await self._send(client.websocket, packet) @@ -844,7 +859,12 @@ class SignalingServer: if client.authenticated and isinstance(packet, (AuthLoginPacket, AuthRegisterPacket, AuthResumePacket)): await self._send( client.websocket, - AuthResultPacket(type="auth_result", ok=False, message="Already authenticated."), + AuthResultPacket( + type="auth_result", + ok=False, + message="Already authenticated.", + authPolicy=self._auth_policy(), + ), ) return True @@ -861,7 +881,12 @@ class SignalingServer: client.session_token = None await self._send( client.websocket, - AuthResultPacket(type="auth_result", ok=True, message="Logged out."), + AuthResultPacket( + type="auth_result", + ok=True, + message="Logged out.", + authPolicy=self._auth_policy(), + ), ) await client.websocket.close() return True @@ -870,7 +895,12 @@ class SignalingServer: except AuthError as exc: await self._send( client.websocket, - AuthResultPacket(type="auth_result", ok=False, message=str(exc)), + AuthResultPacket( + type="auth_result", + ok=False, + message=str(exc), + authPolicy=self._auth_policy(), + ), ) return True @@ -890,6 +920,7 @@ class SignalingServer: username=session.user.username, role=session.user.role, nickname=client.nickname, + authPolicy=self._auth_policy(), ), ) await self._activate_authenticated_client(client)