diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index b51b37f..06d1b0c 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -18,6 +18,7 @@ import { type MessageView, } from '../../messages/store.js'; import { publishRealtimeEvent } from '../../realtime/publisher.js'; +import { getOwnedGeneral } from '../shared/general.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -30,15 +31,7 @@ export const messagesRouter = router({ }) ) .query(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); const sequence = input.sequence ?? -1; const nationId = general.nationId; @@ -138,15 +131,7 @@ export const messagesRouter = router({ }) ) .query(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); const nationId = general.nationId; const mailboxes = { @@ -190,15 +175,7 @@ export const messagesRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); const src = await buildTargetFromGeneral(ctx.db, general); const now = new Date(); diff --git a/app/game-api/src/router/shared/general.ts b/app/game-api/src/router/shared/general.ts index d6e24a8..8b06814 100644 --- a/app/game-api/src/router/shared/general.ts +++ b/app/game-api/src/router/shared/general.ts @@ -13,3 +13,25 @@ export const getMyGeneral = async (ctx: Pick) => } return general; }; + +export const getOwnedGeneral = async ( + ctx: Pick, + generalId: number +) => { + if (!ctx.auth?.user.id) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const general = await ctx.db.general.findUnique({ + where: { id: generalId }, + }); + if (!general) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' }); + } + if (general.userId !== ctx.auth.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not owned by the authenticated user.', + }); + } + return general; +}; diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index af5420d..d77c654 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { authedProcedure, router } from '../../trpc.js'; +import { getOwnedGeneral } from '../shared/general.js'; export const troopRouter = router({ join: authedProcedure @@ -12,9 +13,10 @@ export const troopRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); const result = await ctx.turnDaemon.requestCommand({ type: 'troopJoin', - generalId: input.generalId, + generalId: general.id, troopId: input.troopId, }); if (!result) { @@ -45,9 +47,10 @@ export const troopRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); const result = await ctx.turnDaemon.requestCommand({ type: 'troopExit', - generalId: input.generalId, + generalId: general.id, }); if (!result) { throw new TRPCError({ diff --git a/app/game-api/src/router/turnDaemon/index.ts b/app/game-api/src/router/turnDaemon/index.ts index 913274c..5ba45d6 100644 --- a/app/game-api/src/router/turnDaemon/index.ts +++ b/app/game-api/src/router/turnDaemon/index.ts @@ -1,6 +1,7 @@ +import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { procedure, router } from '../../trpc.js'; +import { authedProcedure, router } from '../../trpc.js'; const zRunReason = z.enum(['schedule', 'manual', 'poke']); @@ -10,8 +11,27 @@ const zTurnRunBudget = z.object({ catchUpCap: z.number().int().positive(), }); +const turnDaemonAdminProcedure = authedProcedure.use(({ ctx, next }) => { + const roles = ctx.auth?.user.roles ?? []; + const profileName = ctx.profile.name; + const canManageProfile = + roles.includes('superuser') || + roles.includes('admin') || + roles.includes('admin.superuser') || + roles.includes('admin.profiles.manage') || + roles.includes('admin.profiles.manage:*') || + roles.includes(`admin.profiles.manage:${profileName}`); + if (!canManageProfile) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Profile administration permission is required.', + }); + } + return next(); +}); + export const turnDaemonRouter = router({ - run: procedure + run: turnDaemonAdminProcedure .input( z.object({ reason: zRunReason, @@ -28,7 +48,7 @@ export const turnDaemonRouter = router({ }); return { accepted: true, requestId }; }), - pause: procedure + pause: turnDaemonAdminProcedure .input( z .object({ @@ -43,7 +63,7 @@ export const turnDaemonRouter = router({ }); return { accepted: true, requestId }; }), - resume: procedure + resume: turnDaemonAdminProcedure .input( z .object({ @@ -58,7 +78,7 @@ export const turnDaemonRouter = router({ }); return { accepted: true, requestId }; }), - status: procedure + status: turnDaemonAdminProcedure .input( z .object({ diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index b4167c6..bd353bf 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -13,6 +13,7 @@ import { shiftGeneralTurns, shiftNationTurns, } from '../../turns/reservedTurns.js'; +import { getOwnedGeneral } from '../shared/general.js'; const buildShiftAmountSchema = (maxTurns: number) => z @@ -34,7 +35,7 @@ export const turnsRouter = router({ .query(async ({ ctx, input }) => { const [worldState, general] = await Promise.all([ ctx.db.worldState.findFirst(), - ctx.db.general.findUnique({ where: { id: input.generalId } }), + getOwnedGeneral(ctx, input.generalId), ]); if (!worldState) { @@ -44,13 +45,6 @@ export const turnsRouter = router({ }); } - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } - const [city, nation, nationGenerals] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ @@ -85,15 +79,7 @@ export const turnsRouter = router({ }) ) .query(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + await getOwnedGeneral(ctx, input.generalId); return listGeneralTurns(ctx.db, input.generalId); }), @@ -104,15 +90,7 @@ export const turnsRouter = router({ }) ) .query(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', @@ -142,15 +120,7 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + await getOwnedGeneral(ctx, input.generalId); const turns = await setGeneralTurn( ctx.db, @@ -169,15 +139,7 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + await getOwnedGeneral(ctx, input.generalId); const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount); return { ok: true, turns }; @@ -196,15 +158,7 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', @@ -236,15 +190,7 @@ export const turnsRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const general = await ctx.db.general.findUnique({ - where: { id: input.generalId }, - }); - if (!general) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'General not found.', - }); - } + const general = await getOwnedGeneral(ctx, input.generalId); if (general.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index b619a37..7d4540d 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -1,18 +1,23 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import type { WorldStateRow } from '../../context.js'; +import { + type WorldStateRow, + zWorldStateConfig, + zWorldStateMeta, +} from '../../context.js'; import { procedure, router } from '../../trpc.js'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; +import { getOwnedGeneral } from '../shared/general.js'; const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, currentYear: row.currentYear, currentMonth: row.currentMonth, tickSeconds: row.tickSeconds, - config: row.config, - meta: row.meta, + config: zWorldStateConfig.parse(row.config), + meta: zWorldStateMeta.parse(row.meta), updatedAt: row.updatedAt.toISOString(), }); @@ -34,6 +39,9 @@ export const worldRouter = router({ }) ) .query(async ({ ctx, input }) => { + if (input.generalId !== undefined) { + await getOwnedGeneral(ctx, input.generalId); + } const map = await loadWorldMap(ctx, input); if (!map) { throw new TRPCError({ diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 3117b91..5255022 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -26,7 +26,7 @@ const profile: GameProfile = { const buildGeneralRow = (overrides?: Partial): GeneralRow => { const base: GeneralRow = { id: 1, - userId: null, + userId: 'user-1', name: '테스트', nationId: 0, cityId: 1, @@ -123,7 +123,7 @@ const buildContext = (options?: { }, profile.name ); - const auth: GameSessionTokenPayload = options?.auth ?? { + const defaultAuth: GameSessionTokenPayload = { version: 1, profile: profile.name, issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), @@ -133,10 +133,11 @@ const buildContext = (options?: { id: 'user-1', username: 'tester', displayName: 'Tester', - roles: [], + roles: ['admin'], }, sanctions: {}, }; + const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth; return { db: db as unknown as DatabaseClient, turnDaemon: transport, @@ -172,8 +173,8 @@ describe('appRouter', () => { currentYear: 1, currentMonth: 2, tickSeconds: 600, - config: { seed: 123 }, - meta: { label: 'sample' }, + config: { maxUserCnt: 500, hiddenSeed: 'config-secret' }, + meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' }, updatedAt: new Date('2026-01-01T00:00:00Z'), }; @@ -182,9 +183,50 @@ describe('appRouter', () => { expect(response?.scenarioCode).toBe('default'); expect(response?.currentYear).toBe(1); + expect(response?.config).toEqual({ maxUserCnt: 500 }); + expect(response?.meta).toEqual({ otherTextInfo: 'sample' }); expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z'); }); + it('requires profile administration permission for turn daemon control', async () => { + const auth: GameSessionTokenPayload = { + version: 1, + profile: profile.name, + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-user', + user: { + id: 'user-1', + username: 'tester', + displayName: 'Tester', + roles: ['user'], + }, + sanctions: {}, + }; + const caller = appRouter.createCaller(buildContext({ auth })); + + await expect(caller.turnDaemon.run({ reason: 'manual' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.turnDaemon.pause()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.turnDaemon.resume()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.turnDaemon.status()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + + it('rejects unauthenticated turn daemon control', async () => { + const caller = appRouter.createCaller(buildContext({ auth: null })); + + await expect(caller.turnDaemon.pause()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + it('returns status from transport', async () => { const transport = new InMemoryTurnDaemonTransport({ state: 'paused', @@ -238,4 +280,99 @@ describe('appRouter', () => { expect(response[0]?.action).toBe('che_포상'); expect(response[0]?.index).toBe(0); }); + + it('rejects another user general across actor-owned routers', async () => { + const general = buildGeneralRow({ id: 15, userId: 'user-2' }); + const transport = new InMemoryTurnDaemonTransport(); + const caller = appRouter.createCaller(buildContext({ general, transport })); + + await expect(caller.turns.getCommandTable({ generalId: general.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.turns.reserved.getGeneral({ generalId: general.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.turns.reserved.getNation({ generalId: general.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.turns.reserved.setGeneral({ + generalId: general.id, + turnIndex: 0, + action: '휴식', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.turns.reserved.shiftGeneral({ + generalId: general.id, + amount: 1, + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: '휴식', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.turns.reserved.shiftNation({ + generalId: general.id, + amount: 1, + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.messages.getRecent({ generalId: general.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.getOld({ + generalId: general.id, + to: 1, + type: 'private', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 0, + text: 'test', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.troop.join({ generalId: general.id, troopId: 3 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.troop.exit({ generalId: general.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.world.getMap({ + generalId: general.id, + showMe: true, + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(transport.commands).toHaveLength(0); + }); + + it('rejects unauthenticated general-scoped map views', async () => { + const general = buildGeneralRow({ id: 16 }); + const caller = appRouter.createCaller(buildContext({ general, auth: null })); + + await expect(caller.world.getMap({ generalId: general.id })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); }); diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index a6fa92d..f389a5c 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -99,10 +99,14 @@ export const appRouter = router({ password: input.password, displayName: input.displayName, }); - await ctx.users.updateRoles(created.id, ['superuser']); - const session = await ctx.sessions.createSession(created); + const bootstrappedUser = { + ...created, + roles: ['superuser'], + }; + await ctx.users.updateRoles(created.id, bootstrappedUser.roles); + const session = await ctx.sessions.createSession(bootstrappedUser); return { - user: toPublicUser(created), + user: toPublicUser(bootstrappedUser), sessionToken: session.sessionToken, issuedAt: session.issuedAt, }; diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index fbef070..c02bc38 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -8,6 +8,7 @@ import { createGatewayApiContext } from '../src/context.js'; import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; import { appRouter } from '../src/router.js'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; +import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; const buildCaller = () => { const users = createInMemoryUserRepository(); @@ -87,13 +88,47 @@ const buildCaller = () => { orchestrator, profileStatus, requestHeaders: {}, - prisma: {} as unknown as GatewayPrismaClient, + prisma: { + appUser: { + findFirst: async () => null, + }, + } as unknown as GatewayPrismaClient, }) ); return { caller, oauthSessions }; }; describe('gateway auth flow', () => { + it('carries the bootstrap superuser role into game sessions', async () => { + const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN; + process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token'; + try { + const { caller } = buildCaller(); + const bootstrap = await caller.auth.bootstrapLocal({ + token: 'bootstrap-test-token', + username: 'admin', + password: 'secretpass', + displayName: 'Admin', + }); + + expect(bootstrap.user.roles).toEqual(['superuser']); + + const issued = await caller.auth.issueGameSession({ + sessionToken: bootstrap.sessionToken, + profile: 'che:default', + }); + const payload = decryptGameSessionToken(issued.gameToken, 'test-secret'); + + expect(payload?.user.roles).toEqual(['superuser']); + } finally { + if (previousToken === undefined) { + delete process.env.GATEWAY_BOOTSTRAP_TOKEN; + } else { + process.env.GATEWAY_BOOTSTRAP_TOKEN = previousToken; + } + } + }); + it('registers and issues a game session', async () => { const { caller, oauthSessions } = buildCaller(); const oauthSession = await oauthSessions.createSession({ diff --git a/docs/e2e-orchestrator-tests.md b/docs/e2e-orchestrator-tests.md index 4b4ab23..a57af46 100644 --- a/docs/e2e-orchestrator-tests.md +++ b/docs/e2e-orchestrator-tests.md @@ -75,12 +75,15 @@ For profile alignment: - Wait for PM2 to report the processes as `online`. 5) **Verify API <-> daemon** - - Call `turnDaemon.status` over tRPC and expect a non-null status. + - Exchange a gateway session carrying `superuser`, `admin`, or + `admin.profiles.manage[:]` for a game access token. + - Call `turnDaemon.status` with that administrator token and expect a + non-null status. - Send a mutation command that expects a result (e.g., `troop.join`) and verify `commandResult` is received. 6) **Verify realtime events** - - Trigger a run via `turnDaemon.run`. + - Trigger a run via `turnDaemon.run` with the administrator token. - Subscribe to `sammo:${profileName}:realtime:events` and wait for `turnCompleted`. @@ -94,7 +97,9 @@ For profile alignment: Mandatory checks: - PM2 reports both processes as online. -- `turnDaemon.status` responds within the timeout. +- Authenticated profile administration is required for every + `turnDaemon.run/pause/resume/status` call. +- `turnDaemon.status` responds within the timeout for the administrator token. - A command with `commandResult` returns a success response. - A `turnCompleted` realtime event is observed after a `run` command. diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 2d58d19..951ac22 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -69,5 +69,5 @@ These are loaded from `.env.ci` and can be overridden per run. - `auth.bootstrapLocal` only works when no users exist; the test resets the DB to satisfy this precondition. - `profiles.installNow` seeds the scenario and auto-creates the admin general. -- The test runs turn processing via `turnDaemon.run` and validates founding - rules at the third turn. +- The test runs turn processing via administrator-authorized + `turnDaemon.run` and validates founding rules at the third turn. diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 892c929..d4b63b2 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -293,6 +293,15 @@ describe('auction integration flow', () => { }, }); + const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: adminSessionRef.value ?? '', + profile: 'che:2', + }); + const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({ + gatewayToken: adminGatewayToken.gameToken, + }); + gameAccessRef.value = adminAccess.accessToken; + for (const user of demoUsers) { const login = await gatewayClient.auth.login.mutate({ username: user.username, diff --git a/tools/integration-tests/test/orchestrator.e2e.test.ts b/tools/integration-tests/test/orchestrator.e2e.test.ts index 8cc4565..0205e09 100644 --- a/tools/integration-tests/test/orchestrator.e2e.test.ts +++ b/tools/integration-tests/test/orchestrator.e2e.test.ts @@ -192,10 +192,14 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => { for (const name of names) { try { await manager.stop(name); - } catch {} + } catch { + // Cleanup is best-effort when the process is already stopped. + } try { await manager.delete(name); - } catch {} + } catch { + // Cleanup is best-effort when the process is already deleted. + } } }; @@ -237,7 +241,9 @@ const waitForHttpOk = async (url: string, timeoutMs = 15_000) => { if (status >= 200 && status < 300) { return; } - } catch {} + } catch { + // The service can refuse connections while PM2 is still starting it. + } await sleep(250); } throw new Error(`health check timeout: ${url}`); @@ -445,10 +451,12 @@ describe('pm2 orchestrator e2e', () => { const gatewayUrl = `http://localhost:${gatewayServer.config.port}`; const gameUrl = `http://localhost:${apiPort}`; const adminSessionRef: { value?: string } = {}; + const adminGameAccessRef: { value?: string } = {}; const accessTokenRef: { value?: string } = {}; const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef); const gameTrpcPath = process.env.GAME_TRPC_PATH ?? process.env.TRPC_PATH ?? '/trpc'; const gameClientPublic = createGameClient(gameUrl, gameTrpcPath, { value: undefined }); + const gameClientAdmin = createGameClient(gameUrl, gameTrpcPath, adminGameAccessRef); const gameClientAuthed = createGameClient(gameUrl, gameTrpcPath, accessTokenRef); const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({ @@ -496,7 +504,16 @@ describe('pm2 orchestrator e2e', () => { await waitForHttpOk(`${gameUrl}/healthz`); - await waitForTurnDaemonStatus(gameClientPublic); + const adminGameSession = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: adminSessionRef.value ?? '', + profile: profileName, + }); + const adminAccess = await gameClientPublic.auth.exchangeGatewayToken.mutate({ + gatewayToken: adminGameSession.gameToken, + }); + adminGameAccessRef.value = adminAccess.accessToken; + + await waitForTurnDaemonStatus(gameClientAdmin); const login = await gatewayClient.auth.login.mutate({ username: 'e2e-user', @@ -547,7 +564,7 @@ describe('pm2 orchestrator e2e', () => { timeoutMs: 30_000, }); - const runStatus = await runTurn(gameClientPublic); + const runStatus = await runTurn(gameClientAdmin); expect(runStatus.lastRunAt).toBeTruthy(); const realtimeEvent = await ssePromise;