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 fs from 'node:fs/promises';
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 {
createGamePostgresConnector,
@@ -205,6 +210,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);
@@ -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',
+8 -4
View File
@@ -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;
}
+27 -3
View File
@@ -65,6 +65,12 @@ type NavigationFixture = {
boardAccessKind: string | null;
}>;
dashboardRequests?: DashboardBundleInput[];
trpcRequests?: Array<{
operations: string[];
method: string;
url: string;
body: unknown;
}>;
dashboardGrantHeaders?: Array<string | null>;
};
@@ -82,9 +88,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<string, unknown>;
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<string, unknown>;
const entry = (parsed[String(index)] ?? parsed) as { json?: 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) => {
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)) &&
@@ -2725,6 +2741,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) =>
@@ -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<AppRouter>({
links: [
httpBatchLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() {
const token = getSessionToken();
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 type { AppRouter } from '@sammo-ts/game-api';
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
const getGameToken = (): string | null => {
@@ -15,6 +15,7 @@ export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers({ opList }) {
const token = getGameToken();
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
+2
View File
@@ -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,
@@ -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();
@@ -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<Record<string, unknown>> = [];
@@ -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) => {
@@ -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 ({
@@ -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) => {
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.goto('lobby');
@@ -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)]);
}
@@ -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,
}),
],
+3
View File
@@ -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<AppRouter>({
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<AppRouter>({
links: [
httpLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() {
const token = getSessionToken();
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 './gateway/profileStatus.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);
}
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);