Implement server-authoritative roles and Shift+Z admin flows

This commit is contained in:
Jage9
2026-02-27 03:37:20 -05:00
parent 6ab3325263
commit 52584197e9
14 changed files with 1777 additions and 180 deletions

View File

@@ -40,6 +40,10 @@
"keys": "Slash",
"description": "Start chat"
},
{
"keys": "Shift+Z",
"description": "Admin menu (when your role allows it)"
},
{
"keys": "Comma / Period",
"description": "Previous/next message"

View File

@@ -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.27 R284";
window.CHGRID_WEB_VERSION = "2026.02.27 R285";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -29,6 +29,7 @@ export type MainModeCommand =
| 'locateOrListUsers'
| 'openHelp'
| 'openChat'
| 'openAdminMenu'
| 'chatPrev'
| 'chatNext'
| 'chatFirst'
@@ -61,6 +62,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
if (code === 'KeyP') return shiftKey ? null : 'pingServer';
if (code === 'KeyL') return 'locateOrListUsers';
if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat';
if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : null;
if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev';
if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext';
if (code === 'Escape') return 'escape';

View File

@@ -186,6 +186,28 @@ type AuthPolicy = {
passwordMaxLength: number;
};
type AdminMenuActionId = 'manage_roles' | 'change_user_role' | 'ban_user' | 'unban_user';
type AdminMenuAction = {
id: AdminMenuActionId;
label: string;
};
type AdminRoleSummary = {
id: number;
name: string;
isSystem: boolean;
userCount: number;
permissions: string[];
};
type AdminUserSummary = {
id: string;
username: string;
role: string;
status: 'active' | 'disabled';
};
/** Builds linearized help-view lines from sectioned help content. */
function buildHelpLines(help: HelpData): string[] {
const lines: string[] = [];
@@ -236,6 +258,9 @@ let authMode: 'login' | 'register' = 'login';
let authSessionToken = settings.loadAuthSessionToken();
let authUsername = settings.loadAuthUsername();
let authPolicy: AuthPolicy | null = null;
let authRole = 'user';
let authPermissions = new Set<string>();
let voiceSendAllowed = true;
let pendingAuthRequest = false;
const messageBuffer: string[] = [];
let messageCursor = -1;
@@ -274,6 +299,18 @@ let suppressItemPropertyEchoUntilMs = 0;
let itemPropertiesShowAll = false;
let activeTeleportLoopStop: (() => void) | null = null;
let activeTeleportLoopToken = 0;
const adminMenuActions: AdminMenuAction[] = [];
let adminMenuIndex = 0;
let adminRoles: AdminRoleSummary[] = [];
let adminRoleIndex = 0;
let adminPermissionKeys: string[] = [];
let adminRolePermissionIndex = 0;
let adminRoleDeleteReplacementIndex = 0;
let adminUsers: AdminUserSummary[] = [];
let adminUserIndex = 0;
let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null;
let adminSelectedRoleName = '';
let adminSelectedUsername = '';
let activeTeleport:
| {
startX: number;
@@ -546,6 +583,28 @@ function loadPersistedAuthPolicy(): void {
}
}
/** Returns whether currently authenticated user has a specific permission key. */
function hasPermission(key: string): boolean {
return authPermissions.has(key);
}
/** Applies latest role + permission set from server auth packets. */
function applyAuthPermissions(role: string | null | undefined, permissions: string[] | null | undefined): void {
authRole = String(role || 'user').trim() || 'user';
authPermissions = new Set((permissions || []).map((value) => String(value).trim()).filter((value) => value.length > 0));
applyVoiceSendPermission();
}
/** Applies server-authoritative voice.send permission immediately to local outbound track state. */
function applyVoiceSendPermission(): void {
voiceSendAllowed = hasPermission('voice.send');
if (voiceSendAllowed) {
mediaSession.applyMuteToTrack(state.isMuted);
return;
}
mediaSession.applyMuteToTrack(true);
}
/** Enables/disables the connect button based on state and nickname validity. */
function updateConnectAvailability(): void {
const hasSessionToken = authSessionToken.trim().length > 0;
@@ -1011,6 +1070,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'chat') return 500;
if (mode === 'itemPropertyEdit') return 500;
if (mode === 'micGainEdit') return 8;
if (mode === 'adminRoleNameEdit') return 32;
return null;
}
@@ -1030,7 +1090,13 @@ function pasteIntoActiveTextInput(raw: string): boolean {
/** Whether the current mode uses the shared single-line text editing pipeline. */
function isTextEditingMode(mode: typeof state.mode): boolean {
return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit';
return (
mode === 'nickname' ||
mode === 'chat' ||
mode === 'itemPropertyEdit' ||
mode === 'micGainEdit' ||
mode === 'adminRoleNameEdit'
);
}
/** Applies keyboard edits to the shared text buffer and emits cursor/deletion speech hints. */
@@ -1276,6 +1342,7 @@ async function checkMicPermission(): Promise<boolean> {
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
await mediaSession.setupLocalMedia(audioDeviceId);
applyVoiceSendPermission();
}
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
@@ -1417,6 +1484,7 @@ function sendAuthRequest(): void {
/** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
applyAuthPolicy(message.authPolicy);
applyAuthPermissions('user', []);
setConnectionStatus('Authentication required.');
updateStatus(message.message);
}
@@ -1433,6 +1501,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
authSessionToken = '';
settings.saveAuthSessionToken('');
}
applyAuthPermissions('user', []);
setConnectionStatus(message.message);
mediaSession.setConnecting(false);
updateConnectAvailability();
@@ -1456,6 +1525,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
state.player.nickname = resolved;
}
}
applyAuthPermissions(message.role, message.permissions);
dom.authPassword.value = '';
dom.registerPassword.value = '';
dom.registerPasswordConfirm.value = '';
@@ -1468,6 +1538,7 @@ function logOutAccount(): void {
authUsername = '';
settings.saveAuthSessionToken('');
settings.saveAuthUsername('');
applyAuthPermissions('user', []);
if (state.running) {
signaling.send({ type: 'auth_logout' });
disconnect();
@@ -1477,6 +1548,92 @@ function logOutAccount(): void {
updateConnectAvailability();
}
/** Handles server-pushed role/permission refresh events for the current session. */
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
const hadVoiceSend = voiceSendAllowed;
applyAuthPermissions(message.role, message.permissions);
if (hadVoiceSend && !voiceSendAllowed) {
updateStatus('Voice send permission revoked.');
}
if (!hadVoiceSend && voiceSendAllowed) {
updateStatus('Voice send permission granted.');
}
}
/** Returns available admin-menu root actions based on current permission set. */
function getAvailableAdminActions(): AdminMenuAction[] {
const actions: AdminMenuAction[] = [];
if (hasPermission('role.manage')) {
actions.push({ id: 'manage_roles', label: 'Role management' });
}
if (hasPermission('user.change_role')) {
actions.push({ id: 'change_user_role', label: 'Change user role' });
}
if (hasPermission('user.ban_unban')) {
actions.push({ id: 'ban_user', label: 'Ban user' });
actions.push({ id: 'unban_user', label: 'Unban user' });
}
return actions;
}
/** Handles server role-list response for admin menu flows. */
function handleAdminRolesList(message: Extract<IncomingMessage, { type: 'admin_roles_list' }>): void {
adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b));
if (adminPendingUserAction === 'set_role' && adminSelectedUsername) {
state.mode = 'adminUserRoleSelect';
adminRoleIndex = 0;
const first = adminRoles[0];
if (first) {
updateStatus(`Set ${adminSelectedUsername} role. ${first.name}.`);
audio.sfxUiBlip();
} else {
updateStatus('No roles available.');
audio.sfxUiCancel();
state.mode = 'normal';
adminPendingUserAction = null;
adminSelectedUsername = '';
}
return;
}
state.mode = 'adminRoleList';
adminRoleIndex = 0;
const first = adminRoles[0];
if (first) {
updateStatus(`${first.name}, ${first.userCount}.`);
} else {
updateStatus('No roles found.');
}
audio.sfxUiBlip();
}
/** Handles server user-list response for admin menu flows. */
function handleAdminUsersList(message: Extract<IncomingMessage, { type: 'admin_users_list' }>): void {
adminUsers = [...message.users].sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }));
if (adminUsers.length === 0) {
updateStatus('No users available.');
audio.sfxUiCancel();
state.mode = 'normal';
adminPendingUserAction = null;
return;
}
state.mode = 'adminUserList';
adminUserIndex = 0;
const first = adminUsers[0];
updateStatus(`${first.username}, ${first.role}, ${first.status}.`);
audio.sfxUiBlip();
}
/** Handles structured admin action result packets. */
function handleAdminActionResult(message: Extract<IncomingMessage, { type: 'admin_action_result' }>): void {
updateStatus(message.message);
if (message.ok) {
audio.sfxUiConfirm();
} else {
audio.sfxUiCancel();
}
}
/** Builds dependencies shared by connect/disconnect flow helpers. */
function getConnectionFlowDeps(): ConnectFlowDeps {
return {
@@ -1625,6 +1782,10 @@ const onAppMessage = createOnMessageHandler({
},
handleAuthRequired,
handleAuthResult,
handleAuthPermissions,
handleAdminRolesList,
handleAdminUsersList,
handleAdminActionResult,
isPeerNegotiationReady: () => peerNegotiationReady,
enqueuePendingSignal: (message) => {
pendingSignalMessages.push(message);
@@ -1644,6 +1805,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
let connectedAnnouncement: string | null = null;
if (message.type === 'welcome') {
applyAuthPolicy(message.auth?.policy);
applyAuthPermissions(message.auth?.role, message.auth?.permissions);
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null;
const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown';
connectedAnnouncement = reconnectInFlight
@@ -1715,6 +1877,11 @@ async function setupMediaAfterAuth(): Promise<void> {
/** Toggles local microphone track mute state. */
function toggleMute(): void {
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
state.isMuted = !state.isMuted;
mediaSession.applyMuteToTrack(state.isMuted);
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
@@ -1797,6 +1964,11 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
audio.sfxUiBlip();
return;
case 'openMicGainEdit':
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
state.mode = 'micGainEdit';
state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP);
state.cursorPos = state.nicknameInput.length;
@@ -1807,8 +1979,25 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
audio.sfxUiBlip();
return;
case 'calibrateMicrophone':
if (!voiceSendAllowed) {
updateStatus('Voice send is disabled for this account.');
audio.sfxUiCancel();
return;
}
void calibrateMicInputGain();
return;
case 'openAdminMenu': {
const actions = getAvailableAdminActions();
if (actions.length === 0) {
return;
}
adminMenuActions.splice(0, adminMenuActions.length, ...actions);
adminMenuIndex = 0;
state.mode = 'adminMenu';
updateStatus(`Admin: ${adminMenuActions[0].label}.`);
audio.sfxUiBlip();
return;
}
case 'useItem': {
const carried = getCarriedItem();
if (carried) {
@@ -2425,6 +2614,292 @@ function handleSelectItemModeInput(code: string, key: string): void {
}
}
/** Handles top-level Shift+Z admin menu action selection. */
function handleAdminMenuModeInput(code: string, key: string): void {
if (adminMenuActions.length === 0) {
state.mode = 'normal';
return;
}
const control = handleListControlKey(code, key, adminMenuActions, adminMenuIndex, (entry) => entry.label);
if (control.type === 'move') {
adminMenuIndex = control.index;
updateStatus(adminMenuActions[adminMenuIndex].label);
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = adminMenuActions[adminMenuIndex];
if (!selected) return;
if (selected.id === 'manage_roles') {
signaling.send({ type: 'admin_roles_list' });
updateStatus('Loading roles...');
return;
}
if (selected.id === 'change_user_role') {
adminPendingUserAction = 'set_role';
signaling.send({ type: 'admin_users_list' });
updateStatus('Loading users...');
return;
}
if (selected.id === 'ban_user') {
adminPendingUserAction = 'ban';
signaling.send({ type: 'admin_users_list' });
updateStatus('Loading users...');
return;
}
if (selected.id === 'unban_user') {
adminPendingUserAction = 'unban';
signaling.send({ type: 'admin_users_list' });
updateStatus('Loading users...');
}
return;
}
if (control.type === 'cancel') {
state.mode = 'normal';
updateStatus('Cancelled.');
audio.sfxUiCancel();
}
}
/** Handles role list selection flow, including add-role entry. */
function handleAdminRoleListModeInput(code: string, key: string): void {
const entries: Array<{ label: string; role?: AdminRoleSummary }> = [
...adminRoles.map((role) => ({ label: `${role.name}, ${role.userCount}`, role })),
{ label: 'Add role' },
];
const control = handleListControlKey(code, key, entries, adminRoleIndex, (entry) => entry.label);
if (control.type === 'move') {
adminRoleIndex = control.index;
updateStatus(entries[adminRoleIndex]?.label || '');
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = entries[adminRoleIndex];
if (!selected) return;
if (!selected.role) {
state.mode = 'adminRoleNameEdit';
state.nicknameInput = '';
state.cursorPos = 0;
replaceTextOnNextType = false;
updateStatus('New role name.');
audio.sfxUiBlip();
return;
}
adminSelectedRoleName = selected.role.name;
adminRolePermissionIndex = 0;
state.mode = 'adminRolePermissionList';
updateStatus(`${adminSelectedRoleName} permissions.`);
audio.sfxUiBlip();
return;
}
if (control.type === 'cancel') {
state.mode = 'normal';
updateStatus('Cancelled.');
audio.sfxUiCancel();
}
}
/** Handles role permission toggle and delete flow. */
function handleAdminRolePermissionListModeInput(code: string, key: string): void {
const role = adminRoles.find((entry) => entry.name === adminSelectedRoleName);
if (!role) {
state.mode = 'adminRoleList';
return;
}
const entries = [...adminPermissionKeys, '__delete_role__'];
const control = handleListControlKey(code, key, entries, adminRolePermissionIndex, (entry) =>
entry === '__delete_role__' ? `Delete role ${role.name}` : `${entry} ${role.permissions.includes(entry) ? 'on' : 'off'}`,
);
if (control.type === 'move') {
adminRolePermissionIndex = control.index;
const value = entries[adminRolePermissionIndex];
if (value === '__delete_role__') {
updateStatus(`Delete role ${role.name}.`);
} else {
updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`);
}
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const value = entries[adminRolePermissionIndex];
if (value === '__delete_role__') {
if (role.name === 'admin') {
updateStatus('Admin role cannot be deleted.');
audio.sfxUiCancel();
return;
}
const replacementCandidates = adminRoles.filter((entry) => entry.name !== role.name);
if (replacementCandidates.length === 0) {
updateStatus('No replacement role available.');
audio.sfxUiCancel();
return;
}
adminRoleDeleteReplacementIndex = 0;
state.mode = 'adminRoleDeleteReplacement';
updateStatus(`Replacement role: ${replacementCandidates[0].name}.`);
audio.sfxUiBlip();
return;
}
const nextPermissions = new Set(role.permissions);
if (nextPermissions.has(value)) {
nextPermissions.delete(value);
} else {
nextPermissions.add(value);
}
role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b));
signaling.send({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions });
updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`);
audio.sfxUiBlip();
return;
}
if (control.type === 'cancel') {
state.mode = 'adminRoleList';
updateStatus('Roles.');
audio.sfxUiCancel();
}
}
/** Handles replacement-role selection while deleting a role. */
function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): void {
const candidates = adminRoles.filter((entry) => entry.name !== adminSelectedRoleName);
if (candidates.length === 0) {
state.mode = 'adminRolePermissionList';
return;
}
const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name);
if (control.type === 'move') {
adminRoleDeleteReplacementIndex = control.index;
updateStatus(`Replacement role: ${candidates[adminRoleDeleteReplacementIndex].name}.`);
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const replacement = candidates[adminRoleDeleteReplacementIndex];
signaling.send({
type: 'admin_role_delete',
role: adminSelectedRoleName,
replacementRole: replacement.name,
});
state.mode = 'adminRoleList';
updateStatus(`Deleting ${adminSelectedRoleName}...`);
return;
}
if (control.type === 'cancel') {
state.mode = 'adminRolePermissionList';
updateStatus(`${adminSelectedRoleName} permissions.`);
audio.sfxUiCancel();
}
}
/** Handles user list selection for change-role/ban/unban flows. */
function handleAdminUserListModeInput(code: string, key: string): void {
if (adminUsers.length === 0) {
state.mode = 'normal';
adminPendingUserAction = null;
return;
}
const control = handleListControlKey(code, key, adminUsers, adminUserIndex, (entry) => `${entry.username}, ${entry.role}, ${entry.status}`);
if (control.type === 'move') {
adminUserIndex = control.index;
const selected = adminUsers[adminUserIndex];
updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`);
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = adminUsers[adminUserIndex];
if (!selected) return;
adminSelectedUsername = selected.username;
if (adminPendingUserAction === 'set_role') {
signaling.send({ type: 'admin_roles_list' });
updateStatus(`Select new role for ${selected.username}.`);
return;
}
if (adminPendingUserAction === 'ban') {
signaling.send({ type: 'admin_user_ban', username: selected.username });
state.mode = 'normal';
adminPendingUserAction = null;
updateStatus(`Banning ${selected.username}...`);
return;
}
if (adminPendingUserAction === 'unban') {
signaling.send({ type: 'admin_user_unban', username: selected.username });
state.mode = 'normal';
adminPendingUserAction = null;
updateStatus(`Unbanning ${selected.username}...`);
return;
}
return;
}
if (control.type === 'cancel') {
state.mode = 'adminMenu';
adminPendingUserAction = null;
updateStatus('Admin menu.');
audio.sfxUiCancel();
}
}
/** Handles role selection for a previously selected user target. */
function handleAdminUserRoleSelectModeInput(code: string, key: string): void {
if (adminRoles.length === 0) {
state.mode = 'normal';
adminPendingUserAction = null;
return;
}
const control = handleListControlKey(code, key, adminRoles, adminRoleIndex, (entry) => entry.name);
if (control.type === 'move') {
adminRoleIndex = control.index;
updateStatus(`${adminSelectedUsername}: ${adminRoles[adminRoleIndex].name}.`);
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selectedRole = adminRoles[adminRoleIndex];
signaling.send({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name });
state.mode = 'normal';
adminPendingUserAction = null;
updateStatus(`Setting ${adminSelectedUsername} to ${selectedRole.name}...`);
return;
}
if (control.type === 'cancel') {
state.mode = 'adminUserList';
updateStatus('Select user.');
audio.sfxUiCancel();
}
}
/** Handles text edit for new-role creation from admin role list. */
function handleAdminRoleNameEditModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code);
if (editAction === 'submit') {
const name = state.nicknameInput.trim().toLowerCase();
if (!name) {
updateStatus('Role name required.');
audio.sfxUiCancel();
return;
}
signaling.send({ type: 'admin_role_create', name });
state.mode = 'adminRoleList';
state.nicknameInput = '';
state.cursorPos = 0;
replaceTextOnNextType = false;
updateStatus(`Creating role ${name}...`);
return;
}
if (editAction === 'cancel') {
state.mode = 'adminRoleList';
state.nicknameInput = '';
state.cursorPos = 0;
replaceTextOnNextType = false;
updateStatus('Cancelled.');
audio.sfxUiCancel();
return;
}
applyTextInputEdit(code, key, 32, ctrlKey, true);
}
const itemPropertyEditor = createItemPropertyEditor({
state,
signalingSend: (message) => signaling.send(message as OutgoingMessage),
@@ -2607,6 +3082,14 @@ function setupInputHandlers(): void {
listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey),
addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey),
selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey),
adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey),
adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey),
adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey),
adminRoleDeleteReplacement: (currentCode, currentKey) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey),
adminUserList: (currentCode, currentKey) => handleAdminUserListModeInput(currentCode, currentKey),
adminUserRoleSelect: (currentCode, currentKey) => handleAdminUserRoleSelectModeInput(currentCode, currentKey),
adminRoleNameEdit: (currentCode, currentKey, currentCtrlKey) =>
handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey),
itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey),
itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) =>
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),

View File

@@ -72,6 +72,10 @@ type MessageHandlerDeps = {
playClockAnnouncement: (sounds: string[], x: number, y: number, range?: number) => void;
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
handleAuthPermissions: (message: Extract<IncomingMessage, { type: 'auth_permissions' }>) => void;
handleAdminRolesList: (message: Extract<IncomingMessage, { type: 'admin_roles_list' }>) => void;
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void;
isPeerNegotiationReady: () => boolean;
enqueuePendingSignal: (message: Extract<IncomingMessage, { type: 'signal' }>) => void;
};
@@ -89,6 +93,18 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
case 'auth_result':
await deps.handleAuthResult(message);
break;
case 'auth_permissions':
deps.handleAuthPermissions(message);
break;
case 'admin_roles_list':
deps.handleAdminRolesList(message);
break;
case 'admin_users_list':
deps.handleAdminUsersList(message);
break;
case 'admin_action_result':
deps.handleAdminActionResult(message);
break;
case 'welcome':
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {

View File

@@ -56,6 +56,7 @@ export const welcomeMessageSchema = z.object({
userId: z.string().nullable().optional(),
username: z.string().nullable().optional(),
role: z.string().nullable().optional(),
permissions: z.array(z.string()).optional(),
policy: z
.object({
usernameMinLength: z.number().int().positive(),
@@ -123,6 +124,7 @@ export const authResultSchema = z.object({
sessionToken: z.string().optional(),
username: z.string().optional(),
role: z.string().optional(),
permissions: z.array(z.string()).optional(),
nickname: z.string().optional(),
authPolicy: z
.object({
@@ -261,6 +263,52 @@ export const itemPianoStatusSchema = z.object({
recordingState: z.enum(['idle', 'recording', 'paused', 'playback']).optional(),
});
export const authPermissionsSchema = z.object({
type: z.literal('auth_permissions'),
role: z.string(),
permissions: z.array(z.string()),
});
const adminRoleSummarySchema = z.object({
id: z.number().int(),
name: z.string(),
isSystem: z.boolean(),
userCount: z.number().int().nonnegative(),
permissions: z.array(z.string()),
});
export const adminRolesListSchema = z.object({
type: z.literal('admin_roles_list'),
roles: z.array(adminRoleSummarySchema),
permissionKeys: z.array(z.string()),
});
export const adminUsersListSchema = z.object({
type: z.literal('admin_users_list'),
users: z.array(
z.object({
id: z.string(),
username: z.string(),
role: z.string(),
status: z.enum(['active', 'disabled']),
}),
),
});
export const adminActionResultSchema = z.object({
type: z.literal('admin_action_result'),
ok: z.boolean(),
action: z.enum([
'role_create',
'role_update_permissions',
'role_delete',
'user_set_role',
'user_ban',
'user_unban',
]),
message: z.string(),
});
export const incomingMessageSchema = z.discriminatedUnion('type', [
authRequiredSchema,
authResultSchema,
@@ -280,6 +328,10 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
itemClockAnnounceSchema,
itemPianoNoteSchema,
itemPianoStatusSchema,
authPermissionsSchema,
adminRolesListSchema,
adminUsersListSchema,
adminActionResultSchema,
]);
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
@@ -289,6 +341,14 @@ export type OutgoingMessage =
| { type: 'auth_login'; username: string; password: string }
| { type: 'auth_resume'; sessionToken: string }
| { type: 'auth_logout' }
| { type: 'admin_roles_list' }
| { type: 'admin_role_create'; name: string }
| { type: 'admin_role_update_permissions'; role: string; permissions: string[] }
| { type: 'admin_role_delete'; role: string; replacementRole: string }
| { type: 'admin_users_list' }
| { type: 'admin_user_set_role'; username: string; role: string }
| { type: 'admin_user_ban'; username: string }
| { type: 'admin_user_unban'; username: string }
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
| { type: 'update_position'; x: number; y: number }
| { type: 'teleport_complete'; x: number; y: number }

View File

@@ -39,6 +39,13 @@ export type GameMode =
| 'itemProperties'
| 'itemPropertyEdit'
| 'itemPropertyOptionSelect'
| 'adminMenu'
| 'adminRoleList'
| 'adminRolePermissionList'
| 'adminRoleDeleteReplacement'
| 'adminUserList'
| 'adminUserRoleSelect'
| 'adminRoleNameEdit'
| 'pianoUse';
export type Player = {

View File

@@ -16,6 +16,7 @@ This document is the authoritative keymap for the client.
- `U`: Speak connected users
- `N`: Edit nickname
- `/`: Start chat
- `Shift+Z`: Admin menu (when role permissions allow)
- `,` / `.`: Previous/next message
- `<` / `>`: First/last message
@@ -75,6 +76,20 @@ Applies to effect select, user/item list modes, item selection, item property li
- `Space`: Read tooltip/help for current option (where metadata is available)
- First-letter navigation: jump to next matching entry
## Admin Modes
- `Shift+Z`: Open admin menu
- Admin menu options are permission-gated and include:
- role management
- change user role
- ban user
- unban user
- In admin role management:
- role list includes role user-counts
- `Enter` on role opens permission toggles
- `Enter` on `Add role` opens role name editor
- role delete prompts replacement role selection
## Piano Use Mode
- `1-9` (and `0` for the 10th slot): Switch instrument preset quickly

View File

@@ -14,6 +14,13 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `auth_login`: authenticate with username/password.
- `auth_resume`: resume prior session via stored session token.
- `auth_logout`: revoke current session and disconnect.
- `admin_roles_list`: request server role list (with user counts + permission sets).
- `admin_role_create`: create role.
- `admin_role_update_permissions`: replace one role permission set.
- `admin_role_delete`: delete role with replacement role reassignment.
- `admin_users_list`: request user list for admin actions.
- `admin_user_set_role`: set target user role.
- `admin_user_ban` / `admin_user_unban`: disable/enable user account.
- `update_position`: client movement intent; server enforces world bounds and movement rate policy.
- `teleport_complete`: client signals teleport landing; server rebroadcasts spatial landing cue.
- `update_nickname`: nickname change request (server enforces uniqueness).
@@ -28,6 +35,10 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `auth_required`: authentication challenge after websocket connect.
- `auth_result`: auth success/failure and session/account metadata.
- `auth_permissions`: server-pushed live role/permission refresh for current session.
- `admin_roles_list`: role list response payload.
- `admin_users_list`: user list response payload.
- `admin_action_result`: structured result for admin actions.
- `welcome`: initial snapshot with users/items plus server UI/world metadata.
- `signal`: forwarded WebRTC offer/answer/ICE.
- `update_position`, `update_nickname`, `user_left`: presence updates.
@@ -72,6 +83,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `userId`
- `username`
- `role`
- `permissions`
- `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.
@@ -103,6 +115,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
- repeated auth failures are rate-limited by IP and IP+identity windows
- auth failures include small randomized response jitter to reduce high-resolution probing
- Client validates incoming packet shapes and applies runtime behavior.
- Server is authoritative for role/permission checks on every privileged packet.
- `voice.send` permission changes are pushed at runtime via `auth_permissions`.
- Sound/media field normalization uses shared server policy helpers:
- `none/off` normalize to empty values
- bare filenames normalize to `sounds/<name>` for sound-reference fields

View File

@@ -9,6 +9,7 @@
- includes `authPolicy` limits for username/password.
5. Client sends `auth_login`, `auth_register`, or `auth_resume`.
6. Server sends `auth_result`.
- includes role + permissions for authenticated session.
7. Server sends `welcome` with users/items snapshot.
8. Client:
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
@@ -44,6 +45,10 @@ 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 + `authPolicy`.
- `auth_permissions`: live permission refresh (role + permission set) after role/permission admin changes.
- `admin_roles_list`: role metadata + user counts + permission keys for role management UI.
- `admin_users_list`: user metadata list for role/ban admin flows.
- `admin_action_result`: success/error for role/user admin mutations.
- `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.
@@ -67,6 +72,12 @@ Core incoming message effects:
- If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart.
- Connect/reconnect status message is emitted from `welcome` and includes server version.
## Authorization Runtime
- Server enforces item/chat/nickname/voice/admin permissions for each packet.
- Role and permission changes apply live to connected users without reconnect.
- `voice.send` revocation is pushed immediately via `auth_permissions`; client mutes outbound voice track.
## Disconnect/Cleanup
On disconnect:

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ class ClientConnection:
user_id: str | None = None
username: str | None = None
role: str = "user"
permissions: set[str] | None = None
session_token: str | None = None
nickname: str = "user..."
saved_x: int | None = None

View File

@@ -63,6 +63,47 @@ class AuthLogoutPacket(BasePacket):
type: Literal["auth_logout"]
class AdminRolesListPacket(BasePacket):
type: Literal["admin_roles_list"]
class AdminRoleCreatePacket(BasePacket):
type: Literal["admin_role_create"]
name: str = Field(min_length=1, max_length=32)
class AdminRoleUpdatePermissionsPacket(BasePacket):
type: Literal["admin_role_update_permissions"]
role: str = Field(min_length=1, max_length=32)
permissions: list[str]
class AdminRoleDeletePacket(BasePacket):
type: Literal["admin_role_delete"]
role: str = Field(min_length=1, max_length=32)
replacementRole: str = Field(min_length=1, max_length=32)
class AdminUsersListPacket(BasePacket):
type: Literal["admin_users_list"]
class AdminUserSetRolePacket(BasePacket):
type: Literal["admin_user_set_role"]
username: str = Field(min_length=1, max_length=128)
role: str = Field(min_length=1, max_length=32)
class AdminUserBanPacket(BasePacket):
type: Literal["admin_user_ban"]
username: str = Field(min_length=1, max_length=128)
class AdminUserUnbanPacket(BasePacket):
type: Literal["admin_user_unban"]
username: str = Field(min_length=1, max_length=128)
class PingPacket(BasePacket):
type: Literal["ping"]
clientSentAt: int
@@ -131,6 +172,14 @@ ClientPacket = (
| AuthLoginPacket
| AuthResumePacket
| AuthLogoutPacket
| AdminRolesListPacket
| AdminRoleCreatePacket
| AdminRoleUpdatePermissionsPacket
| AdminRoleDeletePacket
| AdminUsersListPacket
| AdminUserSetRolePacket
| AdminUserBanPacket
| AdminUserUnbanPacket
| PingPacket
| ItemAddPacket
| ItemPickupPacket
@@ -176,10 +225,17 @@ class AuthResultPacket(BasePacket):
sessionToken: str | None = None
username: str | None = None
role: str | None = None
permissions: list[str] | None = None
nickname: str | None = None
authPolicy: dict | None = None
class AuthPermissionsPacket(BasePacket):
type: Literal["auth_permissions"]
role: str
permissions: list[str]
class UserLeftPacket(BasePacket):
type: Literal["user_left"]
id: str
@@ -343,3 +399,43 @@ class ItemPianoStatusPacket(BasePacket):
"playback_stopped",
]
recordingState: Literal["idle", "recording", "paused", "playback"] | None = None
class AdminRoleSummary(BaseModel):
id: int
name: str
isSystem: bool
userCount: int
permissions: list[str]
class AdminRolesListResultPacket(BasePacket):
type: Literal["admin_roles_list"]
roles: list[AdminRoleSummary]
permissionKeys: list[str]
class AdminUserSummary(BaseModel):
id: str
username: str
role: str
status: Literal["active", "disabled"]
class AdminUsersListResultPacket(BasePacket):
type: Literal["admin_users_list"]
users: list[AdminUserSummary]
class AdminActionResultPacket(BasePacket):
type: Literal["admin_action_result"]
ok: bool
action: Literal[
"role_create",
"role_update_permissions",
"role_delete",
"user_set_role",
"user_ban",
"user_unban",
]
message: str

View File

@@ -48,10 +48,22 @@ from .items.types.clock.time_format import parse_alarm_time_flexible
from .models import (
AuthLoginPacket,
AuthLogoutPacket,
AuthPermissionsPacket,
AuthRegisterPacket,
AuthRequiredPacket,
AuthResultPacket,
AuthResumePacket,
AdminActionResultPacket,
AdminRoleCreatePacket,
AdminRoleDeletePacket,
AdminRoleUpdatePermissionsPacket,
AdminRolesListPacket,
AdminRolesListResultPacket,
AdminUserBanPacket,
AdminUserSetRolePacket,
AdminUserUnbanPacket,
AdminUsersListPacket,
AdminUsersListResultPacket,
BroadcastChatMessagePacket,
BroadcastNicknamePacket,
BroadcastPositionPacket,
@@ -225,6 +237,61 @@ class SignalingServer:
"passwordMaxLength": self.auth_service.password_max_length,
}
@staticmethod
def _sorted_permissions(values: set[str] | tuple[str, ...] | None) -> list[str]:
"""Return deterministic sorted permission list."""
if not values:
return []
return sorted(str(value) for value in values if str(value).strip())
def _client_has_permission(self, client: ClientConnection, key: str) -> bool:
"""Return whether one authenticated client currently has a permission key."""
if not client.authenticated or not client.user_id:
return False
if client.permissions is None:
client.permissions = self.auth_service.get_user_permissions(client.user_id)
return key in client.permissions
def _refresh_client_permissions(self, client: ClientConnection) -> list[str]:
"""Refresh one client's role/permissions from auth storage and return permissions list."""
if not client.user_id:
client.permissions = set()
return []
user = self.auth_service.get_user_by_id(client.user_id)
if user is None:
client.permissions = set()
return []
client.role = user.role
client.permissions = set(user.permissions)
return self._sorted_permissions(client.permissions)
async def _send_auth_permissions(self, client: ClientConnection) -> None:
"""Push one authenticated client's current role + permission set."""
permissions = self._refresh_client_permissions(client)
await self._send(
client.websocket,
AuthPermissionsPacket(
type="auth_permissions",
role=client.role,
permissions=permissions,
),
)
async def _sync_permissions_for_user_ids(self, user_ids: list[str]) -> None:
"""Refresh and push permissions for active websocket clients matching user ids."""
wanted = {str(user_id) for user_id in user_ids}
if not wanted:
return
for active in self.clients.values():
if not active.user_id or active.user_id not in wanted:
continue
await self._send_auth_permissions(active)
def _flush_state_save(self) -> None:
"""Immediately flush pending state persistence and clear debounce state."""
@@ -412,6 +479,14 @@ class SignalingServer:
actor_name = client.username or client.nickname or actor_id
return actor_id, actor_name
@staticmethod
def _owns_item(client: ClientConnection, item: WorldItem) -> bool:
"""Return whether the authenticated client is the creator/owner of an item."""
if not client.user_id:
return False
return item.createdBy == client.user_id
def _get_item_emit_range(self, item: WorldItem) -> int:
"""Return effective emit range for one item with sane bounds."""
@@ -1211,6 +1286,7 @@ class SignalingServer:
"userId": client.user_id,
"username": client.username,
"role": client.role if client.authenticated else None,
"permissions": self._sorted_permissions(client.permissions),
"policy": self._auth_policy(),
},
)
@@ -1230,6 +1306,7 @@ class SignalingServer:
client.x = random.randrange(self.grid_size)
client.y = random.randrange(self.grid_size)
now_ms = self.item_service.now_ms()
self._refresh_client_permissions(client)
client.last_position_update_ms = now_ms
client.movement_window_index = self._movement_window_index(now_ms)
client.movement_window_steps_used = 0
@@ -1324,6 +1401,7 @@ class SignalingServer:
if client.session_token:
self.auth_service.revoke(client.session_token)
client.session_token = None
client.permissions = set()
LOGGER.info("auth logout id=%s ip=%s username=%s", client.id, self._client_ip(client), client.username)
await self._send(
client.websocket,
@@ -1387,6 +1465,7 @@ class SignalingServer:
client.user_id = session.user.id
client.username = session.user.username
client.role = session.user.role
client.permissions = set(session.user.permissions)
client.session_token = session.token
client.nickname = session.user.last_nickname or client.nickname
client.saved_x = session.user.last_x
@@ -1400,6 +1479,7 @@ class SignalingServer:
sessionToken=session.token,
username=session.user.username,
role=session.user.role,
permissions=self._sorted_permissions(session.user.permissions),
nickname=client.nickname,
authPolicy=self._auth_policy(),
),
@@ -1449,6 +1529,220 @@ class SignalingServer:
BroadcastChatMessagePacket(type="chat_message", message=self_message, system=True),
)
async def _send_admin_action_result(
self,
client: ClientConnection,
*,
ok: bool,
action: Literal[
"role_create",
"role_update_permissions",
"role_delete",
"user_set_role",
"user_ban",
"user_unban",
],
message: str,
) -> None:
"""Send one structured admin action result packet to caller."""
await self._send(
client.websocket,
AdminActionResultPacket(type="admin_action_result", ok=ok, action=action, message=message),
)
async def _handle_admin_packet(self, client: ClientConnection, packet: ClientPacket) -> bool:
"""Handle role/user administration packets with permission checks."""
if not isinstance(
packet,
(
AdminRolesListPacket,
AdminRoleCreatePacket,
AdminRoleUpdatePermissionsPacket,
AdminRoleDeletePacket,
AdminUsersListPacket,
AdminUserSetRolePacket,
AdminUserBanPacket,
AdminUserUnbanPacket,
),
):
return False
async def deny(action: str, message: str) -> None:
await self._send_admin_action_result(client, ok=False, action=action, message=message)
if isinstance(packet, AdminRolesListPacket):
if not (
self._client_has_permission(client, "role.manage")
or self._client_has_permission(client, "user.change_role")
):
await deny("role_update_permissions", "Not authorized.")
return True
roles = self.auth_service.list_roles_with_counts()
await self._send(
client.websocket,
AdminRolesListResultPacket(
type="admin_roles_list",
roles=roles,
permissionKeys=self.auth_service.list_all_permissions(),
),
)
return True
if isinstance(packet, AdminUsersListPacket):
if not (
self._client_has_permission(client, "user.change_role")
or self._client_has_permission(client, "user.ban_unban")
):
await deny("user_set_role", "Not authorized.")
return True
users = self.auth_service.list_users_for_admin()
await self._send(client.websocket, AdminUsersListResultPacket(type="admin_users_list", users=users))
return True
if isinstance(packet, AdminRoleCreatePacket):
if not self._client_has_permission(client, "role.manage"):
await deny("role_create", "Not authorized.")
return True
try:
created = self.auth_service.create_role(packet.name)
except AuthError as exc:
await deny("role_create", str(exc))
return True
LOGGER.info("role created actor=%s role=%s", client.user_id, created["name"])
await self._send_admin_action_result(client, ok=True, action="role_create", message=f"Created role {created['name']}.")
return True
if isinstance(packet, AdminRoleUpdatePermissionsPacket):
if not self._client_has_permission(client, "role.manage"):
await deny("role_update_permissions", "Not authorized.")
return True
affected_user_ids = self.auth_service.list_connected_user_ids_for_role(packet.role)
try:
assigned = self.auth_service.update_role_permissions(packet.role, packet.permissions)
except AuthError as exc:
await deny("role_update_permissions", str(exc))
return True
LOGGER.info(
"role permissions updated actor=%s role=%s permission_count=%d",
client.user_id,
packet.role,
len(assigned),
)
await self._sync_permissions_for_user_ids(affected_user_ids)
await self._send_admin_action_result(
client,
ok=True,
action="role_update_permissions",
message=f"Updated permissions for {packet.role}.",
)
return True
if isinstance(packet, AdminRoleDeletePacket):
if not self._client_has_permission(client, "role.manage"):
await deny("role_delete", "Not authorized.")
return True
try:
affected_usernames, replacement = self.auth_service.delete_role(packet.role, packet.replacementRole)
except AuthError as exc:
await deny("role_delete", str(exc))
return True
affected_ids = [
user_id
for username in affected_usernames
for user_id in [self.auth_service.get_user_id_by_username(username)]
if user_id is not None
]
await self._sync_permissions_for_user_ids(affected_ids)
LOGGER.info(
"role deleted actor=%s role=%s replacement=%s affected=%d",
client.user_id,
packet.role,
replacement,
len(affected_usernames),
)
await self._send_admin_action_result(
client,
ok=True,
action="role_delete",
message=f"Deleted role {packet.role}; reassigned {len(affected_usernames)} users to {replacement}.",
)
return True
if isinstance(packet, AdminUserSetRolePacket):
if not self._client_has_permission(client, "user.change_role"):
await deny("user_set_role", "Not authorized.")
return True
target_id = self.auth_service.get_user_id_by_username(packet.username)
try:
username = self.auth_service.set_user_role(packet.username, packet.role, actor_user_id=client.user_id)
except AuthError as exc:
await deny("user_set_role", str(exc))
return True
if target_id:
await self._sync_permissions_for_user_ids([target_id])
LOGGER.info("user role changed actor=%s target=%s role=%s", client.user_id, username, packet.role)
await self._send_admin_action_result(
client,
ok=True,
action="user_set_role",
message=f"Set role for {username} to {packet.role}.",
)
return True
if isinstance(packet, AdminUserBanPacket):
if not self._client_has_permission(client, "user.ban_unban"):
await deny("user_ban", "Not authorized.")
return True
target_id = self.auth_service.get_user_id_by_username(packet.username)
try:
username = self.auth_service.set_user_status(packet.username, "disabled")
except AuthError as exc:
await deny("user_ban", str(exc))
return True
if target_id:
await self._sync_permissions_for_user_ids([target_id])
for active in list(self.clients.values()):
if active.user_id != target_id:
continue
await self._send(
active.websocket,
AuthResultPacket(type="auth_result", ok=False, message="Account is disabled."),
)
await active.websocket.close()
LOGGER.info("user banned actor=%s target=%s", client.user_id, username)
await self._send_admin_action_result(
client,
ok=True,
action="user_ban",
message=f"Banned {username}.",
)
return True
if isinstance(packet, AdminUserUnbanPacket):
if not self._client_has_permission(client, "user.ban_unban"):
await deny("user_unban", "Not authorized.")
return True
target_id = self.auth_service.get_user_id_by_username(packet.username)
try:
username = self.auth_service.set_user_status(packet.username, "active")
except AuthError as exc:
await deny("user_unban", str(exc))
return True
if target_id:
await self._sync_permissions_for_user_ids([target_id])
LOGGER.info("user unbanned actor=%s target=%s", client.user_id, username)
await self._send_admin_action_result(
client,
ok=True,
action="user_unban",
message=f"Unbanned {username}.",
)
return True
return True
async def _handle_message(self, client: ClientConnection, raw_message: str) -> None:
"""Decode, validate, and route one inbound client packet."""
@@ -1470,6 +1764,8 @@ class SignalingServer:
client.authenticated = True
client.user_id = client.user_id or client.id
client.username = client.username or client.nickname
client.role = "admin"
client.permissions = set(self.auth_service.list_all_permissions())
if await self._handle_auth_packet(client, packet):
return
@@ -1480,6 +1776,9 @@ class SignalingServer:
)
return
if await self._handle_admin_packet(client, packet):
return
if isinstance(packet, UpdatePositionPacket):
if not self._is_in_bounds(packet.x, packet.y):
PACKET_LOGGER.warning(
@@ -1585,6 +1884,18 @@ class SignalingServer:
return
if isinstance(packet, UpdateNicknamePacket):
if not self._client_has_permission(client, "profile.update_nickname"):
await self._send(
client.websocket,
NicknameResultPacket(
type="nickname_result",
accepted=False,
requestedNickname=packet.nickname,
effectiveNickname=client.nickname,
reason="Not authorized to change nickname.",
),
)
return
requested_nickname = packet.nickname.strip()
if not requested_nickname:
await self._send(
@@ -1676,6 +1987,16 @@ class SignalingServer:
return
if isinstance(packet, ChatMessagePacket):
if not self._client_has_permission(client, "chat.send"):
await self._send(
client.websocket,
BroadcastChatMessagePacket(
type="chat_message",
message="You are not allowed to send chat messages.",
system=True,
),
)
return
await self._broadcast(
BroadcastChatMessagePacket(
type="chat_message",
@@ -1695,6 +2016,9 @@ class SignalingServer:
return
if isinstance(packet, ItemAddPacket):
if not self._client_has_permission(client, "item.create"):
await self._send_item_result(client, False, "add", "Not authorized to create items.")
return
if not is_known_item_type(packet.itemType):
await self._send_item_result(client, False, "add", "Unknown item type.")
return
@@ -1744,6 +2068,11 @@ class SignalingServer:
if item.carrierId is None and (item.x != client.x or item.y != client.y):
await self._send_item_result(client, False, "pickup", "Item is not on your square.", item.id)
return
can_pickup_any = self._client_has_permission(client, "item.pickup_drop.any")
can_pickup_own = self._client_has_permission(client, "item.pickup_drop.own") and self._owns_item(client, item)
if not can_pickup_any and not can_pickup_own:
await self._send_item_result(client, False, "pickup", "Not authorized to pick up this item.", item.id)
return
item.carrierId = client.id
item.x = client.x
item.y = client.y
@@ -1776,6 +2105,11 @@ class SignalingServer:
if not self._is_in_bounds(packet.x, packet.y):
await self._send_item_result(client, False, "drop", "Drop position is out of bounds.", item.id)
return
can_drop_any = self._client_has_permission(client, "item.pickup_drop.any")
can_drop_own = self._client_has_permission(client, "item.pickup_drop.own") and self._owns_item(client, item)
if not can_drop_any and not can_drop_own:
await self._send_item_result(client, False, "drop", "Not authorized to drop this item.", item.id)
return
item.carrierId = None
item.x = packet.x
item.y = packet.y
@@ -1808,6 +2142,11 @@ class SignalingServer:
if item.carrierId is None and (item.x != client.x or item.y != client.y):
await self._send_item_result(client, False, "delete", "Item is not on your square.", item.id)
return
can_delete_any = self._client_has_permission(client, "item.delete.any")
can_delete_own = self._client_has_permission(client, "item.delete.own") and self._owns_item(client, item)
if not can_delete_any and not can_delete_own:
await self._send_item_result(client, False, "delete", "Not authorized to delete this item.", item.id)
return
LOGGER.info(
"item deleted by=%s item_id=%s type=%s title=%s",
client.nickname,
@@ -1833,6 +2172,9 @@ class SignalingServer:
return
if isinstance(packet, ItemUsePacket):
if not self._client_has_permission(client, "item.use"):
await self._send_item_result(client, False, "use", "Not authorized to use items.")
return
item = self.items.get(packet.itemId)
if not item:
await self._send_item_result(client, False, "use", "Item not found.")
@@ -1918,6 +2260,9 @@ class SignalingServer:
return
if isinstance(packet, ItemSecondaryUsePacket):
if not self._client_has_permission(client, "item.use"):
await self._send_item_result(client, False, "secondary_use", "Not authorized to use items.")
return
item = self.items.get(packet.itemId)
if not item:
await self._send_item_result(client, False, "secondary_use", "Item not found.")
@@ -1965,6 +2310,8 @@ class SignalingServer:
return
if isinstance(packet, ItemPianoNotePacket):
if not self._client_has_permission(client, "item.use"):
return
item = self.items.get(packet.itemId)
if not item or item.type != "piano":
return
@@ -2021,6 +2368,9 @@ class SignalingServer:
return
if isinstance(packet, ItemPianoRecordingPacket):
if not self._client_has_permission(client, "item.use"):
await self._send_item_result(client, False, "use", "Not authorized to use items.")
return
item = self.items.get(packet.itemId)
if not item or item.type != "piano":
await self._send_item_result(client, False, "use", "Piano not found.")
@@ -2111,6 +2461,11 @@ class SignalingServer:
if item.carrierId is None and (item.x != client.x or item.y != client.y):
await self._send_item_result(client, False, "update", "Item is not on your square.", item.id)
return
can_edit_any = self._client_has_permission(client, "item.edit.any")
can_edit_own = self._client_has_permission(client, "item.edit.own") and self._owns_item(client, item)
if not can_edit_any and not can_edit_own:
await self._send_item_result(client, False, "update", "Not authorized to edit this item.", item.id)
return
if packet.title is not None:
title = packet.title.strip()
if not title:
@@ -2136,6 +2491,8 @@ class SignalingServer:
await self._send_item_result(client, True, "update", f"Updated {item.title}.", item.id)
return
if not self._client_has_permission(client, "voice.send"):
return
target = self._find_by_id(packet.targetId)
if not target:
PACKET_LOGGER.info("signal target not found sender=%s target=%s", client.id, packet.targetId)