feat(hooks): permission-driven gating via useHasPermission

Replace the rank-level family (useHasRankLevel + STAFF_LEVELS
constants + useIsRank) with a permission-driven family that reads
straight from the deployment's `permission_definitions` table — no
more hardcoded SecurityLevel/rank-id thresholds on the client. A new
rank in permission_ranks or a re-shuffling of permission_definitions
rank columns now propagates through the UI automatically.

Renderer-side wire shipped in companion commit
feat/react19-event-bus@159c5eb (UserPermissionsMapParser + Event,
SessionDataManager.getPermissionsSnapshot + USER_PERMISSIONS_UPDATED).

New public API in `useSessionSnapshots.ts`:
- useUserPermissions(): ReadonlyMap<string, number>  — full map
- useHasPermission(key): boolean                       — > 0 ⇒ true
- usePermissionValue(key): number                      — raw 1/2 or 0
- useIsAmbassador() now aliases useHasPermission('acc_ambassador')
- useUserRank() kept for PRESENTATIONAL use only (badge, prefix,
  prefix color) — documented as such in JSDoc; gating must use
  useHasPermission.

Dropped:
- src/api/nitro/session/RankLevels.ts (STAFF_LEVELS constants)
- useHasRankLevel / useIsRank exports (rank-based gating)

11 consumer migrations, each mapped to the right
`permission_definitions.permission_key`:

  - ToolbarView (mod-only chat-input button)        → acc_supporttool
  - ChooserWidgetView (room-object id column)       → acc_supporttool
  - CatalogClassicView (admin toggles)              → acc_catalogfurni
  - CatalogModernView (admin toggles)               → acc_catalogfurni
  - FurniEditorView (panel access)                  → acc_catalogfurni
  - CalendarView (force-open day)                   → acc_calendar_force
  - InfoStandWidgetFurniView (mod buildtools btn)   → acc_anyroomowner
  - AvatarInfoWidgetPetView (canPickUp)             → acc_anyroomowner
  - FurnitureMannequinView (controller mode)        → acc_anyroomowner
  - YouTubePlayerView (isMyRoom)                    → acc_anyroomowner
  - NavigatorRoomInfoView 'settings'                → acc_anyroomowner
  - NavigatorRoomInfoView 'staff_pick'              → acc_staff_pick

Test refresh:
- useUserRank still tested for the presentational shape.
- useHasPermission: true for non-zero, false for absent/zero.
- usePermissionValue: raw 1 / 2 / 0 (default).
- useUserPermissions: full map exposure.
- Runtime promote test: mutate the permissions map + dispatch
  USER_PERMISSIONS_UPDATED, assert useHasPermission flips false→true.
  Locks in the new reactive contract.

Mock unchanged (the test sets getPermissionsSnapshot via vi.mocked).

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
214/214 (213 prior + 1 net new for useUserPermissions). Backward
compatible: older Arcturus deployments don't ship the map → empty
snapshot → every gate is false → mod UI hidden (safe default).
This commit is contained in:
simoleo89
2026-05-19 19:00:10 +02:00
parent 8aa02249e1
commit c7e258e3d1
15 changed files with 164 additions and 127 deletions
+60 -31
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 { useHasRankLevel, useIsRank, useUserRank } from './useSessionSnapshots';
import { useHasPermission, usePermissionValue, useUserPermissions, useUserRank } from './useSessionSnapshots';
// Regression guard for the rolled-back snapshot-consumer migration.
//
@@ -110,10 +110,15 @@ describe('use-between + useSyncExternalStore incompatibility', () =>
});
// ============================================================================
// 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).
// Permission-driven API — useHasPermission / usePermissionValue /
// useUserPermissions / useUserRank (display).
//
// Wire-fed by Arcturus' UserPermissionsMapComposer (resolved against
// permission_definitions for the user's rank) + the legacy
// UserPermissionsComposer (clubLevel/securityLevel/isAmbassador + rank
// metadata extension). The renderer's SessionDataManager keeps two
// snapshots: userDataSnapshot (display info) and permissionsSnapshot
// (gating). Tests fake both sides.
// ============================================================================
const makeFakeDispatcher = () =>
@@ -139,7 +144,7 @@ const makeFakeDispatcher = () =>
};
};
interface FakeSnapshot
interface FakeUserSnapshot
{
securityLevel: number;
rankId: number;
@@ -149,7 +154,7 @@ interface FakeSnapshot
rankPrefixColor: string;
}
const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
const makeUserSnapshot = (overrides: Partial<FakeUserSnapshot> = {}): FakeUserSnapshot => ({
securityLevel: 0,
rankId: 0,
rankName: '',
@@ -159,18 +164,21 @@ const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
...overrides
});
describe('useHasRankLevel + useIsRank + useUserRank', () =>
describe('useHasPermission + usePermissionValue + useUserPermissions', () =>
{
let snapshot: FakeSnapshot;
let userSnapshot: FakeUserSnapshot;
let permissionsSnapshot: ReadonlyMap<string, number>;
let fakeDispatcher: ReturnType<typeof makeFakeDispatcher>;
beforeEach(() =>
{
snapshot = makeSnapshot();
userSnapshot = makeUserSnapshot();
permissionsSnapshot = new Map();
fakeDispatcher = makeFakeDispatcher();
vi.mocked(GetSessionDataManager).mockReturnValue({
getUserDataSnapshot: () => snapshot
getUserDataSnapshot: () => userSnapshot,
getPermissionsSnapshot: () => permissionsSnapshot
} as any);
vi.mocked(GetEventDispatcher).mockReturnValue(fakeDispatcher as any);
@@ -183,9 +191,9 @@ describe('useHasRankLevel + useIsRank + useUserRank', () =>
vi.mocked(GetEventDispatcher).mockReset();
});
it('useUserRank surfaces the full rank metadata from the snapshot', () =>
it('useUserRank surfaces rank metadata for presentational use', () =>
{
snapshot = makeSnapshot({
userSnapshot = makeUserSnapshot({
securityLevel: 5,
rankId: 5,
rankName: 'Moderator',
@@ -206,36 +214,57 @@ describe('useHasRankLevel + useIsRank + useUserRank', () =>
});
});
it('useHasRankLevel compares >= the threshold (5=Mod, 7=Admin in default seed)', () =>
it('useHasPermission returns true for any non-zero value, false for absent/zero', () =>
{
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);
permissionsSnapshot = new Map([
[ 'acc_supporttool', 1 ],
[ 'acc_anyroomowner', 2 ],
[ 'acc_closedice_room', 0 ]
]);
expect(renderHook(() => useHasPermission('acc_supporttool')).result.current).toBe(true);
expect(renderHook(() => useHasPermission('acc_anyroomowner')).result.current).toBe(true);
expect(renderHook(() => useHasPermission('acc_closedice_room')).result.current).toBe(false);
expect(renderHook(() => useHasPermission('acc_unknown_key')).result.current).toBe(false);
});
it('useIsRank matches the exact rank_name from permission_ranks', () =>
it('usePermissionValue returns the raw integer (or 0 if absent)', () =>
{
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);
permissionsSnapshot = new Map([
[ 'acc_supporttool', 1 ],
[ 'acc_anyroomowner', 2 ]
]);
expect(renderHook(() => usePermissionValue('acc_supporttool')).result.current).toBe(1);
expect(renderHook(() => usePermissionValue('acc_anyroomowner')).result.current).toBe(2);
expect(renderHook(() => usePermissionValue('acc_missing')).result.current).toBe(0);
});
it('re-renders when SESSION_DATA_UPDATED fires after a runtime promote', () =>
it('useUserPermissions exposes the full map', () =>
{
snapshot = makeSnapshot({ securityLevel: 1, rankName: 'Member' });
const { result } = renderHook(() => useHasRankLevel(5));
permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ], [ 'acc_ambassador', 1 ] ]);
const { result } = renderHook(() => useUserPermissions());
expect(result.current.size).toBe(2);
expect(result.current.get('acc_supporttool')).toBe(1);
expect(result.current.get('acc_ambassador')).toBe(1);
});
it('re-renders when USER_PERMISSIONS_UPDATED fires after a runtime promote', () =>
{
permissionsSnapshot = new Map();
const { result } = renderHook(() => useHasPermission('acc_supporttool'));
expect(result.current).toBe(false);
act(() =>
{
// 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');
// map reference. The mock's NitroEventType proxy resolves
// any property to `mock:NitroEventType:<PROP>`, so that's
// the wire string useSessionSnapshots subscribes against.
permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ] ]);
fakeDispatcher.dispatch('mock:NitroEventType:USER_PERMISSIONS_UPDATED');
});
expect(result.current).toBe(true);
+65 -26
View File
@@ -59,6 +59,7 @@ const DEFAULT_USER_DATA: Readonly<IUserDataSnapshot> = Object.freeze({
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
const EMPTY_GROUP_BADGES: ReadonlyMap<number, string> = new Map();
const EMPTY_USER_LIST: ReadonlyArray<IRoomUserData> = Object.freeze<IRoomUserData[]>([]) as ReadonlyArray<IRoomUserData>;
const EMPTY_PERMISSIONS: ReadonlyMap<string, number> = new Map();
const DEFAULT_VOLUMES: Readonly<ISoundVolumesSnapshot> = Object.freeze({
system: 0.5,
@@ -129,14 +130,14 @@ export const useIsUserIgnored = (name: string): boolean =>
};
/**
* 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", …).
* Reactive view of the current user's rank metadata — name, badge,
* prefix, prefix color — mirrored from `permission_ranks` via the
* extended `UserPermissionsComposer` wire (Arcturus ≥ 4.2.10). Use
* this in PRESENTATIONAL code only (chat prefix coloring, badge in
* the avatar overlay, "rank" line in the user profile). DO NOT use
* it for gating UI capabilities: prefer the permission-based family
* (`useHasPermission(key)`) below, which is dynamic against
* `permission_definitions` and survives rank renumbering.
*/
export interface IUserRank
{
@@ -163,31 +164,69 @@ export const useUserRank = (): IUserRank =>
};
/**
* 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).
* Resolved permission map for the current user, mirroring
* `permission_definitions` filtered to the user's rank. Backed by
* `SessionDataManager.getPermissionsSnapshot()` and invalidated by
* `USER_PERMISSIONS_UPDATED` (Arcturus dispatches the underlying
* packet at login + after every `setRank`).
*
* Values: 1 = ALLOWED, 2 = ROOM_OWNER (legacy gate that requires
* the user to also be the room owner). Absent key = DISALLOWED.
*
* Empty Map when the connected emulator doesn't ship the extension
* (older deployments) — `useHasPermission` then returns false for
* every key, which hides mod-only UI by default (safe).
*/
export const useHasRankLevel = (minLevel: number): boolean =>
useUserDataSnapshot().securityLevel >= minLevel;
export const useUserPermissions = (): ReadonlyMap<string, number> =>
useExternalSnapshot(
subscribeTo(NitroEventType.USER_PERMISSIONS_UPDATED),
() =>
{
const manager = GetSessionDataManager();
if(!manager || typeof manager.getPermissionsSnapshot !== 'function') return EMPTY_PERMISSIONS;
return manager.getPermissionsSnapshot();
}
);
/**
* 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.
* Reactive predicate: does the current user have the named
* permission (ALLOWED or ROOM_OWNER)? `key` must match a row in
* `permission_definitions.permission_key` (e.g. `'acc_supporttool'`,
* `'acc_anyroomowner'`, `'acc_catalogfurni'`). Prefer this over any
* rank-based gate — it survives rank renumbering and adding new
* ranks without touching the React code.
*/
export const useIsRank = (name: string): boolean => useUserDataSnapshot().rankName === name;
export const useHasPermission = (key: string): boolean =>
{
const permissions = useUserPermissions();
return useMemo(() => (permissions.get(key) ?? 0) > 0, [ permissions, key ]);
};
/**
* 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).
* Reactive raw permission value (1 = ALLOWED, 2 = ROOM_OWNER, 0 if
* absent). Useful for the handful of permissions whose
* `permission_definitions.max_value > 1` (e.g.
* `acc_closedice_room`) where the precise value matters.
*/
export const useIsAmbassador = (): boolean => useUserDataSnapshot().isAmbassador;
export const usePermissionValue = (key: string): number =>
{
const permissions = useUserPermissions();
return useMemo(() => permissions.get(key) ?? 0, [ permissions, key ]);
};
/**
* Reactive ambassador flag. Alias of
* `useHasPermission('acc_ambassador')` — the snapshot also carries
* an explicit `isAmbassador` boolean (legacy
* `UserPermissionsComposer` field), but routing it through the
* permission map keeps a single source of truth for runtime
* promote/demote.
*/
export const useIsAmbassador = (): boolean => useHasPermission('acc_ambassador');
export const useGroupBadgesSnapshot = (): ReadonlyMap<number, string> =>
useExternalSnapshot(