feat(session): resolved permission map snapshot (USER_PERMISSIONS_UPDATED)

Adds the wire pipeline for `Outgoing.UserPermissionsMapComposer = 10070`
shipped by Arcturus-Morningstar-Extended ≥ 4.2.10. The composer sends
the resolved `permission_definitions` map for the current user
(filtered to ALLOWED / ROOM_OWNER entries) at login and after every
`HabboManager.setRank` — so a runtime promote/demote re-derives every
React-side permission gate.

- NitroEventType.USER_PERMISSIONS_UPDATED — new invalidation event.
- IncomingHeader.USER_PERMISSIONS_MAP = 10070.
- UserPermissionsMapParser reads `int count + (string key, int value)*`.
- UserPermissionsMapEvent + NitroMessages registration.
- SessionDataManager._permissions Map + getPermissionsSnapshot()
  referentially-stable per the snapshot convention. New handler
  onUserPermissionsMapEvent copies the parser map into the manager
  (so the parser's mutable reference doesn't leak) and invalidates.
- ISessionDataManager.getPermissionsSnapshot() — public contract.

React-side consumers ship in the companion commit on
feat/react19-modernization. The wire is backward-compatible: older
emulators never send the packet, the snapshot stays empty Map, and
all useHasPermission(key) gates return false (mod-only UI hidden by
default = safe).

Verification: tsgo clean, vitest 138/138.
This commit is contained in:
simoleo89
2026-05-19 18:59:35 +02:00
parent 87e67d58df
commit 159c5eb6e8
9 changed files with 130 additions and 2 deletions
@@ -55,4 +55,11 @@ export interface ISessionDataManager
uiFlags: number;
tags: string[];
getUserDataSnapshot(): Readonly<IUserDataSnapshot>;
/**
* Referentially-stable view of the resolved permission map for
* the current user. Invalidated by `USER_PERMISSIONS_UPDATED`.
* Empty when the connected emulator doesn't ship the extended
* `UserPermissionsMapComposer` (Arcturus ≥ 4.2.10).
*/
getPermissionsSnapshot(): ReadonlyMap<string, number>;
}
File diff suppressed because one or more lines are too long
@@ -255,6 +255,8 @@ export class IncomingHeader
public static USER_OUTFITS = 3315;
public static USER_PERKS = 2586;
public static USER_PERMISSIONS = 411;
// Resolved permission map (Arcturus extension, Outgoing.UserPermissionsMapComposer = 10070).
public static USER_PERMISSIONS_MAP = 10070;
public static USER_PET_ADD = 2101;
public static USER_PET_REMOVE = 3253;
public static USER_PETS = 3522;
@@ -0,0 +1,16 @@
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { UserPermissionsMapParser } from '../../../parser';
export class UserPermissionsMapEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, UserPermissionsMapParser);
}
public getParser(): UserPermissionsMapParser
{
return this.parser as UserPermissionsMapParser;
}
}
@@ -1 +1,2 @@
export * from './UserPermissionsEvent';
export * from './UserPermissionsMapEvent';
@@ -0,0 +1,54 @@
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
/**
* Parses the resolved permission map for the current user
* (Arcturus-Morningstar-Extended ≥ 4.2.10, header
* `Outgoing.UserPermissionsMapComposer = 10070`).
*
* Wire layout:
* int count
* loop: string permission_key + int value (1 = ALLOWED, 2 = ROOM_OWNER)
*
* Only permissions whose `PermissionSetting != DISALLOWED` are sent —
* absence means "no". The renderer-side `SessionDataManager` consumes
* this and exposes it via a snapshot getter; React-side
* `useHasPermission(key)` drives UI gates against the real
* `permission_definitions.permission_key` strings instead of
* deployment-specific rank IDs.
*/
export class UserPermissionsMapParser implements IMessageParser
{
private _permissions: Map<string, number> = new Map();
public flush(): boolean
{
this._permissions = new Map();
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
const count = wrapper.readInt();
const fresh = new Map<string, number>();
for(let i = 0; i < count; i++)
{
const key = wrapper.readString();
const value = wrapper.readInt();
fresh.set(key, value);
}
this._permissions = fresh;
return true;
}
public get permissions(): ReadonlyMap<string, number>
{
return this._permissions;
}
}
@@ -1 +1,2 @@
export * from './UserPermissionsMapParser';
export * from './UserPermissionsParser';
+1
View File
@@ -23,4 +23,5 @@ export class NitroEventType
public static readonly GROUP_BADGES_UPDATED = 'GROUP_BADGES_UPDATED';
public static readonly ROOM_USER_LIST_UPDATED = 'ROOM_USER_LIST_UPDATED';
public static readonly SOUND_VOLUMES_UPDATED = 'SOUND_VOLUMES_UPDATED';
public static readonly USER_PERMISSIONS_UPDATED = 'USER_PERMISSIONS_UPDATED';
}
+46 -1
View File
@@ -1,5 +1,5 @@
import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, IUserDataSnapshot, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api';
import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication';
import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserPermissionsMapEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication';
import { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
@@ -59,6 +59,9 @@ export class SessionDataManager implements ISessionDataManager
private _userDataSnapshot: Readonly<IUserDataSnapshot> | null = null;
private _permissions: Map<string, number> = new Map();
private _permissionsSnapshot: ReadonlyMap<string, number> | null = null;
constructor()
{
this.resetUserInfo();
@@ -71,6 +74,36 @@ export class SessionDataManager implements ISessionDataManager
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SESSION_DATA_UPDATED));
}
private invalidatePermissionsSnapshot(): void
{
this._permissionsSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.USER_PERMISSIONS_UPDATED));
}
/**
* Resolved permission map for the current user — mirror of
* `permission_definitions` for the user's rank, filtered to keys
* with `PermissionSetting != DISALLOWED`. Wire-fed by
* `UserPermissionsMapEvent` (Arcturus ≥ 4.2.10). Older emulators
* that don't ship the new packet leave the snapshot empty; React
* consumers via `useHasPermission(key)` then degrade gracefully
* (every gate returns false → mod UI hidden, which is the safe
* default).
*
* Referentially stable until the next
* `UserPermissionsMapEvent` arrives (e.g. after
* `HabboManager.setRank`).
*/
public getPermissionsSnapshot(): ReadonlyMap<string, number>
{
if(this._permissionsSnapshot) return this._permissionsSnapshot;
this._permissionsSnapshot = new Map(this._permissions) as ReadonlyMap<string, number>;
return this._permissionsSnapshot;
}
public getUserDataSnapshot(): Readonly<IUserDataSnapshot>
{
if(this._userDataSnapshot) return this._userDataSnapshot;
@@ -128,6 +161,7 @@ export class SessionDataManager implements ISessionDataManager
})),
GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))),
GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))),
GetCommunication().registerMessageEvent(new UserPermissionsMapEvent(this.onUserPermissionsMapEvent.bind(this))),
GetCommunication().registerMessageEvent(new AvailabilityStatusMessageEvent(this.onAvailabilityStatusMessageEvent.bind(this))),
GetCommunication().registerMessageEvent(new PetScratchFailedMessageEvent(this.onPetRespectFailed.bind(this))),
GetCommunication().registerMessageEvent(new ChangeUserNameResultMessageEvent(this.onChangeNameUpdateEvent.bind(this))),
@@ -263,6 +297,17 @@ export class SessionDataManager implements ISessionDataManager
this.invalidateUserDataSnapshot();
}
private onUserPermissionsMapEvent(event: UserPermissionsMapEvent): void
{
if(!event || !event.connection) return;
// Copy into our local mutable Map so the parser's reference (which
// is overwritten on every parse() call) can't leak back to consumers.
this._permissions = new Map(event.getParser().permissions);
this.invalidatePermissionsSnapshot();
}
private onAvailabilityStatusMessageEvent(event: AvailabilityStatusMessageEvent): void
{
if(!event || !event.connection) return;