From 05f6f626e24e6c1be38278ab1254b3f9b65fde86 Mon Sep 17 00:00:00 2001 From: Zoo Date: Mon, 6 Jul 2026 18:34:59 +0200 Subject: [PATCH] feat(ingame): add getSelf export for in-game admin menus --- .../internal/ingame.integration.test.ts | 71 ++++++++++++++++++- apps/core/src/routes/internal/ingame.ts | 51 +++++++++++++ apps/resource/src/server/exports.ts | 13 ++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/apps/core/src/routes/internal/ingame.integration.test.ts b/apps/core/src/routes/internal/ingame.integration.test.ts index 2fbba3c..b15946d 100644 --- a/apps/core/src/routes/internal/ingame.integration.test.ts +++ b/apps/core/src/routes/internal/ingame.integration.test.ts @@ -100,6 +100,23 @@ const mockRevokeKick = mock((id: number) => const mockFindAdmin = mock((playerId: number) => playerId === 10 ? { id: 4, username: 'FjamZoo' } : null, ); +const mockGetProfile = mock(async (adminId: number) => + adminId === 4 + ? { + id: 4, + username: 'FjamZoo', + // BAN (1<<1) | SERVER_ACTIONS (1<<11) + effectivePermissions: 2 | 2048, + group: { + id: 1, + name: 'Senior Admin', + permissions: 2 | 2048, + colour: '#ff0000', + icon: null, + }, + } + : null, +); const mockWhitelistAdd = mock((_data: unknown) => true as const); const mockWhitelistRevoke = mock((value: string) => value === 'license:missing' ? undefined : { id: 1, value }, @@ -145,7 +162,7 @@ const fakeRepo = { revokeKick: mockRevokeKick, }, bans: { search: mockBansSearch, revoke: mockBansRevoke }, - admins: { findByPlayerId: mockFindAdmin }, + admins: { findByPlayerId: mockFindAdmin, getProfile: mockGetProfile }, whitelist: { add: mockWhitelistAdd, revokeByValue: mockWhitelistRevoke }, audit: { log: mockAuditLog, list: mockAuditList }, }; @@ -219,6 +236,7 @@ describe('ingame API integration (HTTP)', () => { mockRevokeWarn, mockRevokeKick, mockFindAdmin, + mockGetProfile, mockWhitelistAdd, mockWhitelistRevoke, mockAuditLog, @@ -257,6 +275,57 @@ describe('ingame API integration (HTTP)', () => { expect(res.json()[0].serverId).toBe(1); }); + describe('GET /self', () => { + it('returns admin identity, group and resolved permission keys', async () => { + // serverId 1 -> Alice (playerId 10) -> admin id 4 + const res = await call('GET', '/self?serverId=1'); + expect(res.statusCode).toBe(200); + const body = res.json() as { + isAdmin: boolean; + isMaster: boolean; + adminId: number | null; + username: string | null; + group: { + id: number; + name: string; + colour: string; + icon: string | null; + } | null; + permissions: string[]; + }; + expect(body.isAdmin).toBe(true); + expect(body.isMaster).toBe(false); + expect(body.adminId).toBe(4); + expect(body.username).toBe('FjamZoo'); + expect(body.group).toEqual({ + id: 1, + name: 'Senior Admin', + colour: '#ff0000', + icon: null, + }); + expect(body.permissions).toEqual(['players.ban', 'control.server']); + }); + + it('returns isAdmin:false for an online non-admin', async () => { + // serverId 2 -> Bob (playerId 20) -> not an admin + const res = await call('GET', '/self?serverId=2'); + expect(res.statusCode).toBe(200); + expect(res.json().isAdmin).toBe(false); + expect(res.json().permissions).toEqual([]); + expect(mockGetProfile).not.toHaveBeenCalled(); + }); + + it('404s when the server id is not online', async () => { + const res = await call('GET', '/self?serverId=999'); + expect(res.statusCode).toBe(404); + }); + + it('400s on a missing server id', async () => { + const res = await call('GET', '/self'); + expect(res.statusCode).toBe(400); + }); + }); + it('looks up a player by server id and 404s an unknown one', async () => { const ok = await call('GET', '/players/lookup?serverId=1'); expect(ok.statusCode).toBe(200); diff --git a/apps/core/src/routes/internal/ingame.ts b/apps/core/src/routes/internal/ingame.ts index 161c569..f4eff6a 100644 --- a/apps/core/src/routes/internal/ingame.ts +++ b/apps/core/src/routes/internal/ingame.ts @@ -1,5 +1,7 @@ import { repo } from '@fxmanager/database'; +import { PERMISSION_ACE_KEYS } from '@fxmanager/shared/constants'; import type { PlayerIdentifiers } from '@fxmanager/shared/types'; +import { PermissionManager } from '@fxmanager/shared/utils'; import { resourceAuth } from '../../middleware/resource'; import type { RouteModule } from '../../types'; import { txAdminCompat } from '../../modules/txadmin/compat'; @@ -52,6 +54,14 @@ function parseTarget(raw: unknown): IngameTarget | null { const parsePage = (v: unknown) => typeof v === 'string' && v ? Number(v) : undefined; +function permissionAceKeys(bitfield: number): string[] { + if (PermissionManager.isMaster(bitfield)) + return Object.values(PERMISSION_ACE_KEYS); + return Object.entries(PERMISSION_ACE_KEYS) + .filter(([bit]) => (bitfield & Number(bit)) !== 0) + .map(([, key]) => key); +} + const IngameEndpoints: RouteModule['handler'] = async (fastify, options) => { const { gm, pm } = options; @@ -145,6 +155,47 @@ const IngameEndpoints: RouteModule['handler'] = async (fastify, options) => { return profile; }); + fastify.get('/self', async (request, reply) => { + const q = request.query as { serverId?: string }; + const serverId = q.serverId ? Number(q.serverId) : Number.NaN; + if (!Number.isInteger(serverId)) + return reply.code(400).send({ message: 'invalid_target' }); + + const online = onlineByServerId(serverId); + if (!online) return reply.code(404).send({ message: 'player_not_found' }); + + const notAdmin = { + isAdmin: false, + isMaster: false, + adminId: null, + username: null, + group: null, + permissions: [] as string[], + }; + + const admin = repo.admins.findByPlayerId(online.id); + if (!admin) return notAdmin; + + const profile = await repo.admins.getProfile(admin.id); + if (!profile) return notAdmin; + + return { + isAdmin: true, + isMaster: PermissionManager.isMaster(profile.effectivePermissions), + adminId: profile.id, + username: profile.username, + group: profile.group + ? { + id: profile.group.id, + name: profile.group.name, + colour: profile.group.colour, + icon: profile.group.icon, + } + : null, + permissions: permissionAceKeys(profile.effectivePermissions), + }; + }); + fastify.post('/bans', async (request, reply) => { const body = request.body as { target?: unknown; diff --git a/apps/resource/src/server/exports.ts b/apps/resource/src/server/exports.ts index 77eba7e..018d16f 100644 --- a/apps/resource/src/server/exports.ts +++ b/apps/resource/src/server/exports.ts @@ -81,6 +81,19 @@ exports( }, ); +type SelfInfo = { + isAdmin: boolean; + isMaster: boolean; + adminId: number | null; + username: string | null; + group: { id: number; name: string; colour: string; icon: string | null } | null; + permissions: string[]; +}; + +exports('getSelf', (playerId: number | string) => + get(`/ingame/self?serverId=${Number(playerId)}`), +); + exports('fetchPlayers', () => get('/ingame/players')); exports('getPlayer', (target: Target) =>