merge: tRPC 조회 입력의 JSON 본문 전송을 반영

This commit is contained in:
2026-08-17 11:29:36 +00:00
17 changed files with 138 additions and 22 deletions
+7 -1
View File
@@ -4,7 +4,12 @@ import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { buildGameEventChannel, REALTIME_ACCESS_GRANT_HEADER, type RealtimeViewerIdentity } from '@sammo-ts/common'; import {
buildGameEventChannel,
REALTIME_ACCESS_GRANT_HEADER,
trpcJsonBodyHttpServerOptions,
type RealtimeViewerIdentity,
} from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
@@ -205,6 +210,7 @@ export const createGameApiServer = async () => {
prefix: config.trpcPath, prefix: config.trpcPath,
trpcOptions: { trpcOptions: {
router: appRouter, router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => { createContext: async ({ req }: { req: FastifyRequest }) => {
const token = extractBearerToken(req.headers.authorization); const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
@@ -167,6 +167,26 @@ integration('game API security over HTTP transport', () => {
restoreEnv(); restoreEnv();
}, 30_000); }, 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([ it.each([
{ {
label: 'global suspension', label: 'global suspension',
+8 -4
View File
@@ -131,11 +131,15 @@ const generals = [
]; ];
const parseSort = (route: Route): number => { const parseSort = (route: Route): number => {
const raw = new URL(route.request().url()).searchParams.get('input');
if (!raw) return 9;
try { try {
const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } }; const request = route.request();
return input[0]?.sort ?? input.json?.sort ?? 9; 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 { } catch {
return 9; return 9;
} }
+27 -3
View File
@@ -65,6 +65,12 @@ type NavigationFixture = {
boardAccessKind: string | null; boardAccessKind: string | null;
}>; }>;
dashboardRequests?: DashboardBundleInput[]; dashboardRequests?: DashboardBundleInput[];
trpcRequests?: Array<{
operations: string[];
method: string;
url: string;
body: unknown;
}>;
dashboardGrantHeaders?: Array<string | null>; dashboardGrantHeaders?: Array<string | null>;
}; };
@@ -82,9 +88,13 @@ type DashboardBundleInput = {
}; };
const operationInput = (route: Route, index: number): DashboardBundleInput => { const operationInput = (route: Route, index: number): DashboardBundleInput => {
const input = new URL(route.request().url()).searchParams.get('input'); const request = route.request();
if (!input) return {}; const queryInput = new URL(request.url()).searchParams.get('input');
const parsed = JSON.parse(input) as Record<string, unknown>; const parsed = (request.postData()
? request.postDataJSON()
: queryInput
? JSON.parse(queryInput)
: {}) as Record<string, unknown>;
const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput };
return entry.json ?? (entry as DashboardBundleInput); return entry.json ?? (entry as DashboardBundleInput);
}; };
@@ -364,6 +374,12 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
}); });
await page.route(`**${basePath}/api/trpc/**`, async (route) => { await page.route(`**${basePath}/api/trpc/**`, async (route) => {
const operations = operationNames(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); state.operations.push(...operations);
if ( if (
operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) && operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) &&
@@ -2725,6 +2741,14 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
boardAccessKind: 'unchanged', boardAccessKind: 'unchanged',
}); });
expect(realtimeBundle?.bytes).toBeLessThan(1_000); 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( expect(
state.dashboardRequests?.find( state.dashboardRequests?.find(
(request) => (request) =>
@@ -1,3 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
@@ -13,6 +14,7 @@ export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() { headers() {
const token = getSessionToken(); const token = getSessionToken();
return token ? { 'x-session-token': token } : {}; return token ? { 'x-session-token': token } : {};
+2 -1
View File
@@ -1,6 +1,6 @@
import { REALTIME_ACCESS_GRANT_HEADER, trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; import type { AppRouter } from '@sammo-ts/game-api';
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant'; import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
const getGameToken = (): string | null => { const getGameToken = (): string | null => {
@@ -15,6 +15,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers({ opList }) { headers({ opList }) {
const token = getGameToken(); const token = getGameToken();
const refreshGrant = resolveBatchRealtimeAccessGrant(opList); const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
+2
View File
@@ -4,6 +4,7 @@ import fastifyStatic from '@fastify/static';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { import {
createGatewayPostgresConnector, createGatewayPostgresConnector,
createRedisConnector, createRedisConnector,
@@ -108,6 +109,7 @@ export const createGatewayApiServer = async () => {
prefix: config.trpcPath, prefix: config.trpcPath,
trpcOptions: { trpcOptions: {
router: appRouter, router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) => createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({ createGatewayApiContext({
users, users,
@@ -2,6 +2,7 @@ import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import type { GatewayPrismaClient } from '@sammo-ts/infra'; import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
@@ -94,6 +95,7 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
prefix: '/trpc', prefix: '/trpc',
trpcOptions: { trpcOptions: {
router: appRouter, router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) => createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({ createGatewayApiContext({
users, users,
@@ -168,6 +170,33 @@ const postTrpc = async (
}; };
describe('admin security over HTTP transport', () => { 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 () => { it('does not expose the removed public user-flush mutation', async () => {
const harness = await createHarness(); const harness = await createHarness();
@@ -8,7 +8,7 @@ const operationNames = (route: Route): string[] => {
}; };
const installFixture = async (page: Page) => { 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 deleteAfter: string | null = null;
let graceUntil: string | null = null; let graceUntil: string | null = null;
let specialGrants: Array<Record<string, unknown>> = []; let specialGrants: Array<Record<string, unknown>> = [];
@@ -31,7 +31,7 @@ const installFixture = async (page: Page) => {
const operations = operationNames(route); const operations = operationNames(route);
const body = route.request().postDataJSON() as unknown; const body = route.request().postDataJSON() as unknown;
const results = operations.map((operation) => { const results = operations.map((operation) => {
if (route.request().method() === 'POST') mutations.push({ operation, body }); requests.push({ operation, body });
if (operation === 'me') { if (operation === 'me') {
return response({ return response({
id: 'admin-user', id: 'admin-user',
@@ -202,11 +202,11 @@ const installFixture = async (page: Page) => {
body: JSON.stringify(isBatch ? results : results[0]), 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) => { 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()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/users'); await page.goto('admin/users');
await expect(page.getByRole('region', { name: '계정 목록' })).toBeVisible(); 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 page.getByLabel('탈퇴 전 보존 일수').fill('30');
await deletionButton.click(); await deletionButton.click();
await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible(); await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible();
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); expect(requests.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); expect(requests.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.scheduleDeletion')).toBe(true);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => { const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => {
@@ -9,10 +9,16 @@ const operationNames = (route: Route): string[] => {
}; };
const installGatewayFixture = async (page: Page, roles: string[]) => { const installGatewayFixture = async (page: Page, roles: string[]) => {
const requests: Array<{ method: string; url: string; body: unknown }> = [];
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
}); });
await page.route('**/gateway/api/trpc/**', async (route) => { 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) => { const results = operationNames(route).map((operation) => {
if (operation === 'me') { if (operation === 'me') {
return response({ 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) => { 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'); await page.goto('lobby');
const adminLink = page.getByRole('link', { name: '관리자 페이지' }); 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 navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible(); 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 ({ test('desktop administrator sidebar follows the navbar away and then sticks to the viewport top', async ({
@@ -283,7 +283,8 @@ test('automatically recovers profile details after a transient update outage', a
}); });
test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => { test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => {
await installFixture(page, { lobbyBundleFailures: 1 }); // Keep the scheduled retry in the error state so it cannot race the manual retry after the screenshot.
await installFixture(page, { lobbyBundleFailures: 2 });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('lobby'); await page.goto('lobby');
@@ -180,9 +180,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1); await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1);
} }
const results = names.map((name) => { 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') { if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning, state.resetDefaults)]); return response([profile(state.runtimeRunning, state.resetDefaults)]);
} }
@@ -1,3 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { appRouter } from '@sammo-ts/game-api'; import type { appRouter } from '@sammo-ts/game-api';
@@ -15,6 +16,7 @@ export const createGameTrpc = (profile: string, port: number, gameToken?: string
links: [ links: [
httpBatchLink({ httpBatchLink({
url, url,
...trpcJsonBodyHttpClientOptions,
headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined, headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined,
}), }),
], ],
+3
View File
@@ -1,3 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
@@ -12,6 +13,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() { headers() {
const token = getSessionToken(); const token = getSessionToken();
return token ? { 'x-session-token': token } : {}; return token ? { 'x-session-token': token } : {};
@@ -24,6 +26,7 @@ export const directTrpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
httpLink({ httpLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() { headers() {
const token = getSessionToken(); const token = getSessionToken();
return token ? { 'x-session-token': token } : {}; return token ? { 'x-session-token': token } : {};
+12
View File
@@ -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;
+1
View File
@@ -27,3 +27,4 @@ export * from './auth/accountIconProjection.js';
export * from './logging/formatLegacyLogHtml.js'; export * from './logging/formatLegacyLogHtml.js';
export * from './gateway/profileStatus.js'; export * from './gateway/profileStatus.js';
export * from './game/accessPenalty.js'; export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js';
@@ -147,7 +147,7 @@ const installFixture = async (page: Page): Promise<void> => {
return response(listPayload); return response(listPayload);
} }
if (operation === 'dynasty.getDetail') { 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') return input.includes('999')
? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.') ? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.')
: response(detailPayload); : response(detailPayload);