feat(hooks): rank-based API tied to permission_ranks DB table

Drop the SecurityLevel-named family (useIsModerator / useIsAdmin /
useIsCommunity / useIsPlayerSupport / useHasSecurityLevel /
useUserSecurityLevel) in favour of a rank-based family tied to the
operator's actual `permission_ranks` rows in the Arcturus DB:

- `useUserRank()` returns `{ id, name, level, badge, prefix,
  prefixColor }` derived from the snapshot. Powered by the renderer's
  extended IUserDataSnapshot (companion commit 87e67d5 on
  feat/react19-event-bus).
- `useHasRankLevel(min)` replaces useHasSecurityLevel; consumers
  pass a `permission_ranks.level` threshold from the deployment.
- `useIsRank(name)` matches `permission_ranks.rank_name` exactly.

To avoid bare integers in widget bodies, added a deployment-scoped
constants file at `src/api/nitro/session/RankLevels.ts`:

  export const STAFF_LEVELS = {
      MEMBER: 1, SUPPORT: 4, MOD: 5, SUPER_MOD: 6, ADMIN: 7
  };

A deployment that re-numbers `permission_ranks` only edits this file.

Migrated all 11 consumer reads (same set as the earlier session's
useIsModerator migration plus the audit catch): ToolbarView,
CatalogClassicView, CatalogModernView, ChooserWidgetView,
CalendarView, YouTubePlayerView, FurniEditorView,
InfoStandWidgetFurniView, AvatarInfoWidgetPetView,
FurnitureMannequinView, NavigatorRoomInfoView. The
NavigatorRoomInfoView `staff_pick` permission was previously
`securityLevel >= COMMUNITY (7)` via the renderer-enum wrapper —
ported to `useHasRankLevel(STAFF_LEVELS.ADMIN)` because in the
default seed level 7 is Administrator, which is the actual rank that
gets the `acc_anyroomowner`-style permissions for staff-picking.

Tests refreshed under `useSessionSnapshots.test.tsx`:
- useUserRank surfaces the full metadata block;
- useHasRankLevel does `>=` against the threshold;
- useIsRank exact-matches against rank_name;
- a runtime promote (snapshot mutation + SESSION_DATA_UPDATED
  dispatch) flips the result, locking in the reactive contract.

Mock extended only minimally — kept the SecurityLevel enum class for
any consumer outside the dropped family that still imports it.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213. The Arcturus-side composer change (UserPermissionsComposer
appending the 5 extra fields) is staged but UNCOMMITTED on Arcturus
main (which has unrelated WIP); the wire is backward-compatible so
the React client works against both pre- and post-extension
emulators.
This commit is contained in:
simoleo89
2026-05-19 18:38:31 +02:00
parent c11a6c4699
commit 8aa02249e1
16 changed files with 192 additions and 106 deletions
+66 -43
View File
@@ -5,7 +5,7 @@ import { Component, ReactNode, useSyncExternalStore } from 'react';
import { useBetween } from 'use-between';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GetEventDispatcher, GetSessionDataManager } from '../../nitro-renderer.mock';
import { useHasSecurityLevel, useIsAdmin, useIsCommunity, useIsModerator, useUserSecurityLevel } from './useSessionSnapshots';
import { useHasRankLevel, useIsRank, useUserRank } from './useSessionSnapshots';
// Regression guard for the rolled-back snapshot-consumer migration.
//
@@ -110,15 +110,11 @@ describe('use-between + useSyncExternalStore incompatibility', () =>
});
// ============================================================================
// useHasSecurityLevel + named wrappers — reactive flip on snapshot invalidation
// useHasRankLevel / useIsRank / useUserRank — reactive flip on snapshot
// invalidation, tied to the permission_ranks DB table (rankId / rankName /
// rankBadge / rankPrefix / rankPrefixColor are mirrored on the wire by
// the extended UserPermissionsComposer in Arcturus ≥ 4.2.10).
// ============================================================================
//
// The family hangs off useUserDataSnapshot() which is a useSyncExternalStore
// wrapper. The renderer's real SessionDataManager pushes a frozen snapshot
// out of getUserDataSnapshot() and dispatches a SESSION_DATA_UPDATED event
// whenever a mutator invalidates the cache. These tests fake both sides:
// a mock dispatcher with a real .subscribe(), and a mock SessionDataManager
// whose snapshot can be mutated between dispatches.
const makeFakeDispatcher = () =>
{
@@ -143,20 +139,37 @@ const makeFakeDispatcher = () =>
};
};
describe('useHasSecurityLevel + named wrappers', () =>
interface FakeSnapshot
{
let snapshot: { securityLevel: number };
securityLevel: number;
rankId: number;
rankName: string;
rankBadge: string;
rankPrefix: string;
rankPrefixColor: string;
}
const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
securityLevel: 0,
rankId: 0,
rankName: '',
rankBadge: '',
rankPrefix: '',
rankPrefixColor: '',
...overrides
});
describe('useHasRankLevel + useIsRank + useUserRank', () =>
{
let snapshot: FakeSnapshot;
let fakeDispatcher: ReturnType<typeof makeFakeDispatcher>;
beforeEach(() =>
{
snapshot = { securityLevel: 0 };
snapshot = makeSnapshot();
fakeDispatcher = makeFakeDispatcher();
vi.mocked(GetSessionDataManager).mockReturnValue({
// useSessionSnapshots reads getUserDataSnapshot() and guards on
// `typeof manager.getUserDataSnapshot !== 'function'`, so we
// expose it as a real function returning the mutable test snapshot.
getUserDataSnapshot: () => snapshot
} as any);
@@ -170,48 +183,58 @@ describe('useHasSecurityLevel + named wrappers', () =>
vi.mocked(GetEventDispatcher).mockReset();
});
it('useUserSecurityLevel reads the raw level', () =>
it('useUserRank surfaces the full rank metadata from the snapshot', () =>
{
snapshot = { securityLevel: 7 };
const { result } = renderHook(() => useUserSecurityLevel());
expect(result.current).toBe(7);
snapshot = makeSnapshot({
securityLevel: 5,
rankId: 5,
rankName: 'Moderator',
rankBadge: 'ADM',
rankPrefix: '[MOD]',
rankPrefixColor: '#327fa8'
});
const { result } = renderHook(() => useUserRank());
expect(result.current).toEqual({
id: 5,
name: 'Moderator',
level: 5,
badge: 'ADM',
prefix: '[MOD]',
prefixColor: '#327fa8'
});
});
it('useHasSecurityLevel compares >= the threshold', () =>
it('useHasRankLevel compares >= the threshold (5=Mod, 7=Admin in default seed)', () =>
{
snapshot = { securityLevel: 5 };
const { result } = renderHook(() => useHasSecurityLevel(5));
expect(result.current).toBe(true);
const { result: lowResult } = renderHook(() => useHasSecurityLevel(8));
expect(lowResult.current).toBe(false);
snapshot = makeSnapshot({ securityLevel: 5 });
expect(renderHook(() => useHasRankLevel(5)).result.current).toBe(true);
expect(renderHook(() => useHasRankLevel(6)).result.current).toBe(false);
expect(renderHook(() => useHasRankLevel(7)).result.current).toBe(false);
});
it('named wrappers map to the right thresholds (MODERATOR=5, COMMUNITY=7, ADMINISTRATOR=8)', () =>
it('useIsRank matches the exact rank_name from permission_ranks', () =>
{
snapshot = { securityLevel: 7 }; // COMMUNITY
expect(renderHook(() => useIsModerator()).result.current).toBe(true); // 7 >= 5
expect(renderHook(() => useIsCommunity()).result.current).toBe(true); // 7 >= 7
expect(renderHook(() => useIsAdmin()).result.current).toBe(false); // 7 < 8
snapshot = makeSnapshot({ rankName: 'Moderator' });
expect(renderHook(() => useIsRank('Moderator')).result.current).toBe(true);
expect(renderHook(() => useIsRank('Super Mod')).result.current).toBe(false);
expect(renderHook(() => useIsRank('Administrator')).result.current).toBe(false);
});
it('re-renders when SESSION_DATA_UPDATED fires after the snapshot mutates', () =>
it('re-renders when SESSION_DATA_UPDATED fires after a runtime promote', () =>
{
snapshot = { securityLevel: 0 };
const { result } = renderHook(() => useIsModerator());
snapshot = makeSnapshot({ securityLevel: 1, rankName: 'Member' });
const { result } = renderHook(() => useHasRankLevel(5));
expect(result.current).toBe(false);
// Mutate the snapshot reference (renderer invariant: every
// invalidation produces a NEW frozen object) and dispatch the
// event. The hook's getSnapshot closure reads `snapshot`, so a
// fresh object reference flips React's bailout.
act(() =>
{
snapshot = { securityLevel: 5 };
// The mock's NitroEventType proxy resolves any property to
// `mock:NitroEventType:<PROP>`, so that's the wire string
// useSessionSnapshots subscribes against.
// Renderer invariant: every invalidation produces a NEW
// frozen snapshot object. The mock's NitroEventType proxy
// resolves any property to `mock:NitroEventType:<PROP>`, so
// that's the wire string useSessionSnapshots subscribes against.
snapshot = makeSnapshot({ securityLevel: 5, rankName: 'Moderator' });
fakeDispatcher.dispatch('mock:NitroEventType:SESSION_DATA_UPDATED');
});
+58 -26
View File
@@ -1,4 +1,4 @@
import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType, SecurityLevel } from '@nitrots/nitro-renderer';
import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType } from '@nitrots/nitro-renderer';
import { useMemo } from 'react';
import { useExternalSnapshot } from '../events/useExternalSnapshot';
@@ -48,7 +48,12 @@ const DEFAULT_USER_DATA: Readonly<IUserDataSnapshot> = Object.freeze({
isSystemOpen: false,
isSystemShutdown: false,
uiFlags: 0,
tags: Object.freeze<string[]>([]) as ReadonlyArray<string>
tags: Object.freeze<string[]>([]) as ReadonlyArray<string>,
rankId: 0,
rankName: '',
rankBadge: '',
rankPrefix: '',
rankPrefixColor: ''
}) as Readonly<IUserDataSnapshot>;
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
@@ -124,36 +129,63 @@ export const useIsUserIgnored = (name: string): boolean =>
};
/**
* Reactive raw security level from the user snapshot. Use this when
* you need the numeric level (e.g. to compare against a threshold not
* covered by the named wrappers below); for the common case of "is
* the user at least <X>?", prefer the matching `useIsXxx` predicate.
* Reactive view of the current user's rank, mirrored from the
* `permission_ranks` table via the extended `UserPermissionsComposer`
* wire (Arcturus-Morningstar-Extended ≥ 4.2.10). Use this in UI code
* that needs to display rank metadata (badge, prefix, prefix color)
* or to gate behaviour on the actual deployment rank rather than the
* generic SecurityLevel constants the renderer exposes — those don't
* line up with the rank names operators actually use ("Moderator",
* "Super Mod", "Administrator", …).
*/
export const useUserSecurityLevel = (): number => useUserDataSnapshot().securityLevel;
export interface IUserRank
{
readonly id: number;
readonly name: string;
readonly level: number;
readonly badge: string;
readonly prefix: string;
readonly prefixColor: string;
}
export const useUserRank = (): IUserRank =>
{
const userData = useUserDataSnapshot();
return useMemo<IUserRank>(() => ({
id: userData.rankId,
name: userData.rankName,
level: userData.securityLevel,
badge: userData.rankBadge,
prefix: userData.rankPrefix,
prefixColor: userData.rankPrefixColor
}), [ userData.rankId, userData.rankName, userData.securityLevel, userData.rankBadge, userData.rankPrefix, userData.rankPrefixColor ]);
};
/**
* Reactive predicate: does the current user's security level satisfy
* `>= minLevel`? Mirrors the renderer-side comparison used by
* `SessionDataManager.isModerator` (and its peers) and propagates the
* SESSION_DATA_UPDATED invalidation, so a runtime promote/demote
* re-renders the consumer.
*
* The named wrappers below (`useIsModerator`, `useIsAdmin`, …) are
* one-line shims over this primitive — use them in widget bodies for
* readability; reach for `useHasSecurityLevel(level)` directly only
* when the threshold is dynamic or not covered by a named wrapper.
* Reactive predicate: does the current user's rank level satisfy
* `>= minLevel`? Use this when you want "at least <rank>" semantics
* and have the rank id from your deployment's `permission_ranks`
* table (e.g. 5 for Moderator in the default seed). Replaces the
* older `useHasSecurityLevel` (same wire data, renamed to match the
* DB table semantics).
*/
export const useHasSecurityLevel = (minLevel: number): boolean =>
useUserSecurityLevel() >= minLevel;
export const useIsModerator = (): boolean => useHasSecurityLevel(SecurityLevel.MODERATOR);
export const useIsPlayerSupport = (): boolean => useHasSecurityLevel(SecurityLevel.PLAYER_SUPPORT);
export const useIsCommunity = (): boolean => useHasSecurityLevel(SecurityLevel.COMMUNITY);
export const useIsAdmin = (): boolean => useHasSecurityLevel(SecurityLevel.ADMINISTRATOR);
export const useHasRankLevel = (minLevel: number): boolean =>
useUserDataSnapshot().securityLevel >= minLevel;
/**
* Reactive ambassador flag. Not derived from security level — it's a
* separate boolean on the snapshot.
* Reactive exact-match predicate against the rank name from
* `permission_ranks.rank_name`. Prefer `useHasRankLevel(min)` when
* the gate is "this rank or higher"; reach for `useIsRank('Foo')`
* only when an action must be specific to one rank.
*/
export const useIsRank = (name: string): boolean => useUserDataSnapshot().rankName === name;
/**
* Reactive ambassador flag. Not derived from rank level — it's a
* separate boolean on the snapshot (the emulator computes it server-
* side from the `acc_ambassador` permission, which a deployment can
* grant independently of the rank hierarchy).
*/
export const useIsAmbassador = (): boolean => useUserDataSnapshot().isAmbassador;