fix: align legacy general access call boundaries

This commit is contained in:
2026-07-31 07:33:26 +00:00
parent 821da19731
commit 98a3cf514f
34 changed files with 909 additions and 177 deletions
+2
View File
@@ -71,6 +71,7 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
requestId?: string;
generalAccessTracking?: boolean;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
@@ -102,6 +103,7 @@ export const createGameApiContext = (options: {
}): GameApiContext => {
return {
requestId: options.requestId,
generalAccessTracking: true,
db: options.db,
redis: options.redis,
turnDaemon: options.turnDaemon,
+9 -4
View File
@@ -4,7 +4,13 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic';
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
import {
accessAuthedInputProcedure,
accessReadOnlyAuthedInputProcedure,
authedProcedure,
readOnlyAuthedProcedure,
router,
} from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
@@ -56,7 +62,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
@@ -169,8 +175,7 @@ export const battleRouter = router({
generalsByNation,
};
}),
getGeneralDetail: authedProcedure
.input(
getGeneralDetail: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
+2 -3
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js';
import { getMyGeneral } from '../shared/general.js';
@@ -30,8 +30,7 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
};
export const bettingRouter = router({
getList: authedProcedure
.input(z.object({ req: z.literal('bettingNation').optional() }).optional())
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
.query(async ({ ctx, input }) => {
requireUserId(ctx.auth);
await getMyGeneral(ctx);
+4 -6
View File
@@ -5,7 +5,7 @@ import { promises as fs } from 'fs';
import { randomUUID } from 'crypto';
import sharp, { type WebpOptions } from 'sharp';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -107,7 +107,7 @@ export const boardRouter = router({
canSecret: permission >= 2,
};
}),
getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
const { general, permission } = await getBoardActor(ctx);
assertBoardAccess(permission, input.isSecret);
@@ -159,8 +159,7 @@ export const boardRouter = router({
})),
}));
}),
writeArticle: authedProcedure
.input(
writeArticle: accessAuthedInputProcedure(
z.object({
isSecret: z.boolean(),
title: z.string().trim().max(250),
@@ -189,8 +188,7 @@ export const boardRouter = router({
return { id: post.id };
}),
writeComment: authedProcedure
.input(
writeComment: accessAuthedInputProcedure(
z.object({
postId: z.number().int().positive(),
content: z.string().trim().max(2000),
+6 -10
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -28,7 +28,7 @@ const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' |
};
export const diplomacyRouter = router({
getLetters: authedProcedure.query(async ({ ctx }) => {
getLetters: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -98,8 +98,7 @@ export const diplomacyRouter = router({
permission,
};
}),
sendLetter: authedProcedure
.input(
sendLetter: accessAuthedInputProcedure(
z.object({
destNationId: z.number().int().positive(),
prevId: z.number().int().positive().nullable().optional(),
@@ -217,8 +216,7 @@ export const diplomacyRouter = router({
return { id: created.id };
}),
respondLetter: authedProcedure
.input(
respondLetter: accessAuthedInputProcedure(
z.object({
letterId: z.number().int().positive(),
agree: z.boolean(),
@@ -288,8 +286,7 @@ export const diplomacyRouter = router({
return { ok: true };
}),
rollbackLetter: authedProcedure
.input(z.object({ letterId: z.number().int().positive() }))
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -324,8 +321,7 @@ export const diplomacyRouter = router({
return { ok: true };
}),
destroyLetter: authedProcedure
.input(z.object({ letterId: z.number().int().positive() }))
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
+14 -10
View File
@@ -5,7 +5,14 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
authedProcedure,
router,
} from '../../trpc.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { getMyGeneral } from '../shared/general.js';
@@ -283,7 +290,7 @@ export const generalRouter = router({
penalties,
};
}),
ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => {
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -319,14 +326,11 @@ export const generalRouter = router({
availableAt: result.availableAt ?? null,
};
}),
dieOnPrestart: engineAuthedProcedure
.input(zImmediateActionInput)
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
buildNationCandidate: engineAuthedProcedure
.input(zImmediateActionInput)
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
instantRetreat: engineAuthedProcedure
.input(zImmediateActionInput)
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')),
vacation: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
@@ -342,7 +346,7 @@ export const generalRouter = router({
}
return { ok: true };
}),
setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => {
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
@@ -459,7 +463,7 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
+2 -3
View File
@@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -388,8 +388,7 @@ export const messagesRouter = router({
...messageBuckets,
};
}),
send: authedProcedure
.input(
send: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
import { LogCategory } from '@sammo-ts/infra';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js';
export const getBattleCenter = authedProcedure.query(async ({ ctx }) => {
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -1,12 +1,12 @@
import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { resolveSecretPermission } from '../../shared/secretPermission.js';
import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js';
import { assertNationAccess } from '../shared.js';
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -18,7 +18,7 @@ const experienceLevel = (experience: number): number =>
const dedicationLevel = (dedication: number): number =>
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
assertNationAccess(general);
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, checkSecretMaxPermission, mapGeneralList, resolveChiefStatMin } from '../shared.js';
export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js';
@@ -25,7 +25,7 @@ const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
const defenceTrainText = (value: number): string =>
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -29,7 +29,7 @@ import {
type NationStratRow,
} from '../shared.js';
export const getStratFinan = authedProcedure.query(async ({ ctx }) => {
export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
+4 -4
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js';
@@ -537,7 +537,7 @@ export const npcRouter = router({
permissionLevel,
};
}),
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
@@ -700,7 +700,7 @@ export const npcRouter = router({
return { ok: true };
}),
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
@@ -753,7 +753,7 @@ export const npcRouter = router({
return { ok: true };
}),
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
+2 -3
View File
@@ -8,7 +8,7 @@ import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
type WorldTrendSnapshot = {
@@ -477,8 +477,7 @@ export const publicRouter = router({
intelligence: general.intel,
}));
}),
getNpcList: procedure
.input(
getNpcList: accessInputProcedure(
z
.object({
sort: z.number().int().min(1).max(8).catch(1).optional(),
+3 -5
View File
@@ -6,7 +6,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
@@ -81,8 +81,7 @@ const loadUniqueItems = () => {
};
export const rankingRouter = router({
getBestGeneral: authedProcedure
.input(
getBestGeneral: accessAuthedInputProcedure(
z
.object({
view: z.enum(['user', 'npc']).optional(),
@@ -369,8 +368,7 @@ export const rankingRouter = router({
}
return Array.from(seasonMap.values());
}),
getHallOfFame: procedure
.input(
getHallOfFame: accessInputProcedure(
z.object({
season: z.number().int(),
scenario: z.number().int().optional(),
+2 -2
View File
@@ -7,7 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
@@ -127,7 +127,7 @@ export const tournamentRouter = router({
return store.getState();
}),
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
getSnapshot: authedProcedure.query(async ({ ctx }) => {
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, participants, matches, bets] = await Promise.all([
+2 -2
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
const troopNameSchema = z
@@ -33,7 +33,7 @@ const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit'
};
export const troopRouter = router({
getList: authedProcedure.query(async ({ ctx }) => {
getList: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
if (me.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
+2 -3
View File
@@ -1,7 +1,7 @@
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import { authedProcedure } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -204,8 +204,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
});
});
export const getGeneralDirectory = authedProcedure
.input(z.object({ sort: zDirectorySort }).optional())
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional())
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const sort = input?.sort ?? 9;
+2 -3
View File
@@ -2,8 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, procedure, router } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
@@ -71,7 +70,7 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
export const worldRouter = router({
getNationDirectory,
getGeneralDirectory,
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
getGlobalInfo: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const [nations, cities, diplomacy, map] = await Promise.all([
ctx.db.nation.findMany({ where: { level: { gt: 0 } } }),
+21
View File
@@ -7,6 +7,10 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import {
generalAccessEndpointWeights,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -24,6 +28,13 @@ const zServerId = z.string().trim().min(1).max(64);
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
if (ctx.generalAccessTracking !== true) {
return;
}
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
};
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
@@ -257,6 +268,10 @@ export const yearbookRouter = router({
}
const targetProfileName = input.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash);
if (isCurrentProfile && !shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
const isCurrent =
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
@@ -280,6 +295,9 @@ export const yearbookRouter = router({
if (input.hash && input.hash === hash) {
return { notModified: true, hash };
}
if (shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
return { notModified: false, hash, data };
}
@@ -317,6 +335,9 @@ export const yearbookRouter = router({
if (input.hash && input.hash === hash) {
return { notModified: true, hash };
}
if (shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
return { notModified: false, hash, data };
}),
});
+70 -35
View File
@@ -4,59 +4,86 @@ import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
export const accessPages = [
'front-info',
'nation-info',
'nation-cities',
'global-info',
'nation-list',
'general-list',
'current-city',
'diplomacy',
'nation-generals',
'nation-personnel',
'nation-finance',
'battle-center',
'board',
'best-general',
'hall-of-fame',
'dynasty',
'yearbook',
'nation-betting',
'traffic',
'npc-list',
'my-page',
'npc-control',
'tournament',
'betting',
] as const;
export type AccessPage = (typeof accessPages)[number];
export const accessPageWeights: Record<AccessPage, number> = {
'front-info': 1,
'nation-info': 1,
'nation-cities': 1,
'global-info': 1,
'nation-list': 2,
'general-list': 2,
'current-city': 1,
diplomacy: 1,
'nation-generals': 1,
'nation-personnel': 1,
'nation-finance': 1,
'battle-center': 1,
board: 1,
'best-general': 1,
'hall-of-fame': 1,
dynasty: 1,
yearbook: 1,
'nation-betting': 1,
traffic: 1,
'npc-list': 2,
'my-page': 1,
'npc-control': 1,
tournament: 1,
betting: 1,
};
export const generalAccessEndpointWeights = {
'world.getGeneralDirectory': 2,
'public.getNpcList': 2,
'ranking.getBestGeneral': 1,
'ranking.getHallOfFame': 1,
'tournament.getSnapshot': 1,
'nation.getSecretGeneralList': 1,
'nation.getPersonnelInfo': 1,
'nation.getGeneralList': 1,
'general.ensureDieOnPrestartStatus': 1,
'nation.getStratFinan': 1,
'board.getArticles': 1,
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
'nation.getChiefCenter': 1,
'troop.getList': 1,
'board.writeArticle': 1,
'board.writeComment': 1,
'diplomacy.sendLetter': 1,
'diplomacy.respondLetter': 1,
'diplomacy.rollbackLetter': 1,
'diplomacy.destroyLetter': 1,
'general.buildNationCandidate': 1,
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.simulate': 0,
} as const satisfies Record<string, 0 | 1 | 2>;
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
export const resolveGeneralAccessEndpointWeight = (
path: string,
input: unknown,
currentProfileName: string
): 0 | 1 | 2 | null | undefined => {
const weight = generalAccessEndpointWeights[path as GeneralAccessEndpoint];
if (weight === undefined) {
return undefined;
}
if (path === 'yearbook.getHistory') {
const requestedServer =
typeof input === 'object' && input !== null && 'serverID' in input
? (input as { serverID?: unknown }).serverID
: undefined;
if (requestedServer !== undefined && requestedServer !== currentProfileName) {
return null;
}
}
return weight;
};
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
@@ -262,7 +289,16 @@ export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
weight: number,
now = new Date()
): Promise<boolean> => {
if (!Number.isInteger(weight) || weight < 0) {
throw new RangeError('General access weight must be a non-negative integer.');
}
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
@@ -296,7 +332,6 @@ export const recordGeneralAccess = async (
return false;
}
const weight = accessPageWeights[page];
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, {
+49
View File
@@ -5,6 +5,7 @@ import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from './context.js';
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import { recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight } from './services/generalAccess.js';
const t = initTRPC.context<GameApiContext>().create();
@@ -67,14 +68,48 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
}
});
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
// Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다.
if (ctx.generalAccessTracking !== true) {
return next();
}
const weight = resolveGeneralAccessEndpointWeight(path, input, ctx.profile.name);
if (weight === undefined) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `General access endpoint is not registered: ${path}`,
});
}
if (weight === null) {
return next();
}
await recordGeneralAccessWeight(ctx, weight);
return next();
});
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
export const accessProcedure: typeof procedure = t.procedure
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
@@ -83,3 +118,17 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);