From 2d302a5f917be60c1583a3c7711aa647a22e7552 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 11:12:02 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20tRPC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9D=84=20JSON=20=EB=B3=B8=EB=AC=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저의 Gateway·Game tRPC 클라이언트를 POST JSON 전송으로 통일하고 서버의 query method override를 허용한다. GET mutation 거부 계약과 production Chromium 요청 형태를 회귀 테스트로 고정한다. --- app/game-api/src/server.ts | 7 ++++- .../securityTransport.integration.test.ts | 20 +++++++++++++ app/game-frontend/e2e/directoryLists.spec.ts | 12 +++++--- app/game-frontend/e2e/mainNavigation.spec.ts | 30 +++++++++++++++++-- app/game-frontend/src/utils/gatewayTrpc.ts | 2 ++ app/game-frontend/src/utils/trpc.ts | 2 ++ app/gateway-api/src/server.ts | 2 ++ .../test/adminSecurityTransport.e2e.test.ts | 29 ++++++++++++++++++ .../e2e/admin-account-controls.spec.ts | 14 ++++----- .../e2e/lobby-admin-navigation.spec.ts | 13 +++++++- .../e2e/server-operations.spec.ts | 4 +-- app/gateway-frontend/src/utils/gameTrpc.ts | 2 ++ app/gateway-frontend/src/utils/trpc.ts | 3 ++ packages/common/src/http/trpcTransport.ts | 12 ++++++++ packages/common/src/index.ts | 1 + .../dynasty-parity.spec.ts | 2 +- 16 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 packages/common/src/http/trpcTransport.ts diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 8f37ac7e..77aefa60 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -4,7 +4,11 @@ import fastifyStatic from '@fastify/static'; import path from 'path'; import fs from 'node:fs/promises'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common'; +import { + buildGameEventChannel, + trpcJsonBodyHttpServerOptions, + type RealtimeViewerIdentity, +} from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, @@ -200,6 +204,7 @@ export const createGameApiServer = async () => { prefix: config.trpcPath, trpcOptions: { router: appRouter, + ...trpcJsonBodyHttpServerOptions, createContext: async ({ req }: { req: FastifyRequest }) => { const token = extractBearerToken(req.headers.authorization); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 112b1e5e..c51f3be6 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -167,6 +167,26 @@ integration('game API security over HTTP transport', () => { restoreEnv(); }, 30_000); + it('accepts an authenticated query from a POST JSON body', async () => { + const accessToken = await createAccessToken('json-query-body', {}); + const general = await requestTrpc('general.me', { + method: 'POST', + input: null, + accessToken, + }); + + expect(general.response.status).toBe(200); + expect(general.body).toMatchObject({ + result: { + data: { + general: { + id: generalId, + }, + }, + }, + }); + }); + it.each([ { label: 'global suspension', diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index dfc06f44..abd5dfc2 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -131,11 +131,15 @@ const generals = [ ]; const parseSort = (route: Route): number => { - const raw = new URL(route.request().url()).searchParams.get('input'); - if (!raw) return 9; try { - const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } }; - return input[0]?.sort ?? input.json?.sort ?? 9; + const request = route.request(); + const queryInput = new URL(request.url()).searchParams.get('input'); + const input = (request.postData() + ? request.postDataJSON() + : queryInput + ? JSON.parse(queryInput) + : {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } }; + return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9; } catch { return 9; } diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index bf51dec7..6df050f1 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -63,6 +63,12 @@ type NavigationFixture = { boardAccessKind: string | null; }>; dashboardRequests?: DashboardBundleInput[]; + trpcRequests?: Array<{ + operations: string[]; + method: string; + url: string; + body: unknown; + }>; }; type JsonPatchOperation = { @@ -79,9 +85,13 @@ type DashboardBundleInput = { }; const operationInput = (route: Route, index: number): DashboardBundleInput => { - const input = new URL(route.request().url()).searchParams.get('input'); - if (!input) return {}; - const parsed = JSON.parse(input) as Record; + const request = route.request(); + const queryInput = new URL(request.url()).searchParams.get('input'); + const parsed = (request.postData() + ? request.postDataJSON() + : queryInput + ? JSON.parse(queryInput) + : {}) as Record; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; @@ -361,6 +371,12 @@ const installFixture = async (page: Page, state: NavigationFixture) => { }); await page.route(`**${basePath}/api/trpc/**`, async (route) => { const operations = operationNames(route); + (state.trpcRequests ??= []).push({ + operations, + method: route.request().method(), + url: route.request().url(), + body: route.request().postDataJSON(), + }); state.operations.push(...operations); if ( operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) && @@ -2706,6 +2722,14 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl boardAccessKind: 'unchanged', }); expect(realtimeBundle?.bytes).toBeLessThan(1_000); + const dashboardTransport = state.trpcRequests?.find( + ({ operations, body }) => + operations.includes('dashboard.getContextBundleDelta') && JSON.stringify(body).includes('knownSource') + ); + expect(dashboardTransport).toMatchObject({ method: 'POST' }); + expect(new URL(dashboardTransport?.url ?? '').searchParams.has('input')).toBe(false); + expect(dashboardTransport?.body).toBeTruthy(); + expect(state.trpcRequests?.every(({ method }) => method === 'POST')).toBe(true); expect( state.dashboardRequests?.find( (request) => diff --git a/app/game-frontend/src/utils/gatewayTrpc.ts b/app/game-frontend/src/utils/gatewayTrpc.ts index c77fb975..8b0ae761 100644 --- a/app/game-frontend/src/utils/gatewayTrpc.ts +++ b/app/game-frontend/src/utils/gatewayTrpc.ts @@ -1,3 +1,4 @@ +import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/gateway-api'; @@ -13,6 +14,7 @@ export const gatewayTrpc = createTRPCProxyClient({ links: [ httpBatchLink({ url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + ...trpcJsonBodyHttpClientOptions, headers() { const token = getSessionToken(); return token ? { 'x-session-token': token } : {}; diff --git a/app/game-frontend/src/utils/trpc.ts b/app/game-frontend/src/utils/trpc.ts index 7486b0e8..134d615a 100644 --- a/app/game-frontend/src/utils/trpc.ts +++ b/app/game-frontend/src/utils/trpc.ts @@ -1,3 +1,4 @@ +import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/game-api'; @@ -13,6 +14,7 @@ export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', + ...trpcJsonBodyHttpClientOptions, headers() { const token = getGameToken(); return token ? { authorization: `Bearer ${token}` } : {}; diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index ed109e83..55df425d 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -4,6 +4,7 @@ import fastifyStatic from '@fastify/static'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; import { createGatewayPostgresConnector, createRedisConnector, @@ -108,6 +109,7 @@ export const createGatewayApiServer = async () => { prefix: config.trpcPath, trpcOptions: { router: appRouter, + ...trpcJsonBodyHttpServerOptions, createContext: ({ req }: { req: FastifyRequest }) => createGatewayApiContext({ users, diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index da662e20..86d71fbc 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -2,6 +2,7 @@ import fastify, { type FastifyRequest } from 'fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { afterEach, describe, expect, it } from 'vitest'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; @@ -94,6 +95,7 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin. prefix: '/trpc', trpcOptions: { router: appRouter, + ...trpcJsonBodyHttpServerOptions, createContext: ({ req }: { req: FastifyRequest }) => createGatewayApiContext({ users, @@ -168,6 +170,33 @@ const postTrpc = async ( }; describe('admin security over HTTP transport', () => { + it('accepts query input from a POST JSON body but still rejects a mutation sent as GET', async () => { + const harness = await createHarness(); + + const query = await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken); + expect(query.response.status).toBe(200); + expect(query.body).toMatchObject({ + result: { + data: { + id: harness.admin.id, + }, + }, + }); + + const mutationInput = encodeURIComponent( + JSON.stringify({ json: { sessionToken: harness.adminSessionToken } }) + ); + const mutation = await fetch(`${harness.baseUrl}/trpc/auth.logout?input=${mutationInput}`); + expect(mutation.status).toBe(405); + expect(await mutation.json()).toMatchObject({ + error: { + data: { + code: 'METHOD_NOT_SUPPORTED', + }, + }, + }); + }); + it('does not expose the removed public user-flush mutation', async () => { const harness = await createHarness(); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index 29ae81df..f3c586d3 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -8,7 +8,7 @@ const operationNames = (route: Route): string[] => { }; const installFixture = async (page: Page) => { - const mutations: Array<{ operation: string; body: unknown }> = []; + const requests: Array<{ operation: string; body: unknown }> = []; let deleteAfter: string | null = null; let graceUntil: string | null = null; let specialGrants: Array> = []; @@ -31,7 +31,7 @@ const installFixture = async (page: Page) => { const operations = operationNames(route); const body = route.request().postDataJSON() as unknown; const results = operations.map((operation) => { - if (route.request().method() === 'POST') mutations.push({ operation, body }); + requests.push({ operation, body }); if (operation === 'me') { return response({ id: 'admin-user', @@ -202,11 +202,11 @@ const installFixture = async (page: Page) => { body: JSON.stringify(isBatch ? results : results[0]), }); }); - return mutations; + return requests; }; test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => { - const mutations = await installFixture(page); + const requests = await installFixture(page); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/users'); await expect(page.getByRole('region', { name: '계정 목록' })).toBeVisible(); @@ -247,9 +247,9 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await page.getByLabel('탈퇴 전 보존 일수').fill('30'); await deletionButton.click(); await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible(); - expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); - expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); - expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); + expect(requests.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); + expect(requests.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); + expect(requests.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); await page.setViewportSize({ width: 390, height: 844 }); const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => { diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index e0da9c46..0fe291fd 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -9,10 +9,16 @@ const operationNames = (route: Route): string[] => { }; const installGatewayFixture = async (page: Page, roles: string[]) => { + const requests: Array<{ method: string; url: string; body: unknown }> = []; await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); }); await page.route('**/gateway/api/trpc/**', async (route) => { + requests.push({ + method: route.request().method(), + url: route.request().url(), + body: route.request().postDataJSON(), + }); const results = operationNames(route).map((operation) => { if (operation === 'me') { return response({ @@ -107,10 +113,11 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { ), }); }); + return requests; }; test('bootstrap superuser can navigate the administrator workspace from the lobby', async ({ page }, testInfo) => { - await installGatewayFixture(page, ['superuser']); + const requests = await installGatewayFixture(page, ['superuser']); await page.goto('lobby'); const adminLink = page.getByRole('link', { name: '관리자 페이지' }); @@ -144,6 +151,10 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click(); await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible(); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every(({ method }) => method === 'POST')).toBe(true); + expect(requests.every(({ url }) => !new URL(url).searchParams.has('input'))).toBe(true); + expect(requests.some(({ body }) => JSON.stringify(body).includes('"limit":30'))).toBe(true); }); test('desktop administrator sidebar follows the navbar away and then sticks to the viewport top', async ({ diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index adfcaffb..e2e4b004 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -180,9 +180,7 @@ const installFixture = async (page: Page, state: FixtureState) => { await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1); } const results = names.map((name) => { - if (route.request().method() === 'POST') { - state.requestBodies.push({ operation: name, body }); - } + state.requestBodies.push({ operation: name, body }); if (name === 'admin.profiles.list') { return response([profile(state.runtimeRunning, state.resetDefaults)]); } diff --git a/app/gateway-frontend/src/utils/gameTrpc.ts b/app/gateway-frontend/src/utils/gameTrpc.ts index cfcec0d9..929ea0ad 100644 --- a/app/gateway-frontend/src/utils/gameTrpc.ts +++ b/app/gateway-frontend/src/utils/gameTrpc.ts @@ -1,3 +1,4 @@ +import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { appRouter } from '@sammo-ts/game-api'; @@ -15,6 +16,7 @@ export const createGameTrpc = (profile: string, port: number, gameToken?: string links: [ httpBatchLink({ url, + ...trpcJsonBodyHttpClientOptions, headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined, }), ], diff --git a/app/gateway-frontend/src/utils/trpc.ts b/app/gateway-frontend/src/utils/trpc.ts index dc1b1f68..50ab1b08 100644 --- a/app/gateway-frontend/src/utils/trpc.ts +++ b/app/gateway-frontend/src/utils/trpc.ts @@ -1,3 +1,4 @@ +import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/gateway-api'; @@ -12,6 +13,7 @@ export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + ...trpcJsonBodyHttpClientOptions, headers() { const token = getSessionToken(); return token ? { 'x-session-token': token } : {}; @@ -24,6 +26,7 @@ export const directTrpc = createTRPCProxyClient({ links: [ httpLink({ url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + ...trpcJsonBodyHttpClientOptions, headers() { const token = getSessionToken(); return token ? { 'x-session-token': token } : {}; diff --git a/packages/common/src/http/trpcTransport.ts b/packages/common/src/http/trpcTransport.ts new file mode 100644 index 00000000..d34faeb3 --- /dev/null +++ b/packages/common/src/http/trpcTransport.ts @@ -0,0 +1,12 @@ +/** + * tRPC keeps procedure semantics in its envelope, so browser inputs belong in a JSON body + * instead of a percent-encoded URL query string. + */ +export const trpcJsonBodyHttpClientOptions = { + methodOverride: 'POST', +} as const; + +/** POST may execute queries, while tRPC still rejects mutations sent as GET. */ +export const trpcJsonBodyHttpServerOptions = { + allowMethodOverride: true, +} as const; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e24e19c2..023e0239 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -27,3 +27,4 @@ export * from './auth/accountIconProjection.js'; export * from './logging/formatLegacyLogHtml.js'; export * from './gateway/profileStatus.js'; export * from './game/accessPenalty.js'; +export * from './http/trpcTransport.js'; diff --git a/tools/frontend-legacy-parity/dynasty-parity.spec.ts b/tools/frontend-legacy-parity/dynasty-parity.spec.ts index d26e1927..48d63602 100644 --- a/tools/frontend-legacy-parity/dynasty-parity.spec.ts +++ b/tools/frontend-legacy-parity/dynasty-parity.spec.ts @@ -147,7 +147,7 @@ const installFixture = async (page: Page): Promise => { return response(listPayload); } if (operation === 'dynasty.getDetail') { - const input = new URL(route.request().url()).searchParams.get('input') ?? ''; + const input = route.request().postData() ?? new URL(route.request().url()).searchParams.get('input') ?? ''; return input.includes('999') ? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.') : response(detailPayload);