merge: tRPC 조회 입력의 JSON 본문 전송을 반영
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } : {};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 } : {};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user