feat: Ref 호환 접속 제한과 벌점 초기화 구현

실행 중인 프로필에서만 접속 벌점을 누적하고 제한 임계값과 대상 경로를 Ref 순서에 맞춘다. 자기 턴 명령 성공 시 순간 점수를 같은 flush에서 초기화하며 월간 누적 감쇠는 유지한다. 제한 중 메인 자동 갱신과 실시간 구독을 중지하고 수동 갱신 성공 시 복구한다.
This commit is contained in:
2026-08-15 18:49:41 +00:00
parent a99f8166eb
commit 48d94e5ff2
29 changed files with 982 additions and 232 deletions
@@ -0,0 +1,56 @@
import { createHmac } from 'node:crypto';
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
const profileStatuses = new Set<string>(GATEWAY_PROFILE_STATUSES);
export interface ProfileStatusSource {
get(profileName: string): Promise<GatewayProfileStatus | null>;
}
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const parseProfileStatus = (value: unknown, profileName: string): GatewayProfileStatus => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('invalid gateway profile status projection');
}
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'profileName,status' ||
record.profileName !== profileName ||
typeof record.status !== 'string' ||
!profileStatuses.has(record.status)
) {
throw new Error('invalid gateway profile status projection');
}
return record.status as GatewayProfileStatus;
};
export class GatewayHttpProfileStatusSource implements ProfileStatusSource {
private readonly baseUrl: string;
constructor(
baseUrl: string,
private readonly secret: string,
private readonly timeoutMs = 2_000
) {
this.baseUrl = baseUrl.replace(/\/$/u, '');
}
async get(profileName: string): Promise<GatewayProfileStatus | null> {
const response = await fetch(`${this.baseUrl}/internal/profile-status/${encodeURIComponent(profileName)}`, {
headers: {
'x-sammo-internal-token': deriveInternalToken(this.secret),
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Gateway profile status request failed with HTTP ${response.status}.`);
}
return parseProfileStatus(await response.json(), profileName);
}
}
+6
View File
@@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js';
import type { FlushStore } from './auth/flushStore.js';
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import type { AccountIconSource } from './auth/accountIconSource.js';
import type { ProfileStatusSource } from './auth/profileStatusSource.js';
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
export interface GameProfile {
@@ -99,6 +100,9 @@ export interface GameApiContext {
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
// Runtime context always supplies this. Partial router fixtures may omit it;
// access scoring then fails open and never penalizes a test-only request.
profileStatusSource?: ProfileStatusSource;
}
export const createGameApiContext = (options: {
@@ -118,6 +122,7 @@ export const createGameApiContext = (options: {
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
profileStatusSource: ProfileStatusSource;
}): GameApiContext => {
return {
requestId: options.requestId,
@@ -137,5 +142,6 @@ export const createGameApiContext = (options: {
flushStore: options.flushStore,
gameTokenSecret: options.gameTokenSecret,
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
profileStatusSource: options.profileStatusSource,
};
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js';
@@ -31,7 +31,7 @@ const zContextBundleInput = z
});
export const dashboardRouter = router({
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
const viewerId = ctx.auth?.user.id;
if (!viewerId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
+2 -1
View File
@@ -10,6 +10,7 @@ import {
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
authedProcedure,
engineAuthedProcedure,
router,
@@ -735,7 +736,7 @@ export const generalRouter = router({
})),
};
}),
getRecentRecords: authedProcedure
getRecentRecords: accessLimitAuthedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
+102 -104
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 { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -73,116 +73,114 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
};
export const messagesRouter = router({
getRecent: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
)
.query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
getRecent: accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
};
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
};
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { accessLimitAuthedInputProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -13,77 +13,75 @@ import {
type GeneralLogType,
} from '../shared.js';
export const getGeneralLog = authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
export const getGeneralLog = accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
).query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
return {
type: input.type,
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
});
return {
type: input.type,
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
});
+20 -4
View File
@@ -7,7 +7,13 @@ import type { GameApiContext } from '../../context.js';
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 {
accessPages,
formatGeneralAccessLimitMessage,
generalAccessLimitPages,
getGeneralAccessState,
recordGeneralAccess,
} from '../../services/generalAccess.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
@@ -248,9 +254,19 @@ const sortNpcList = <
export const publicRouter = router({
recordAccess: sessionActivityProcedure
.input(z.object({ page: z.enum(accessPages) }))
.mutation(async ({ ctx, input }) => ({
recorded: await recordGeneralAccess(ctx, input.page),
})),
.mutation(async ({ ctx, input }) => {
const recorded = await recordGeneralAccess(ctx, input.page);
if (ctx.generalAccessTracking === true && generalAccessLimitPages.has(input.page)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
return { recorded };
}),
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
}),
+6 -8
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { loadActionModuleBundle } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
import {
@@ -283,13 +283,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
};
export const turnsRouter = router({
getCommandTable: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
getCommandTable: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
reserved: router({
getGeneral: authedProcedure
.input(
+10 -1
View File
@@ -7,7 +7,12 @@ import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
import {
formatGeneralAccessLimitMessage,
generalAccessEndpointWeights,
getGeneralAccessState,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -31,6 +36,10 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
return;
}
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
const state = ctx.generalAccessTracking === true ? await getGeneralAccessState(ctx) : null;
if (state?.level === 2) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: formatGeneralAccessLimitMessage(state) });
}
};
const parseTextArray = (value: unknown): string[] =>
+6
View File
@@ -25,6 +25,7 @@ import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
@@ -92,6 +93,10 @@ export const createGameApiServer = async () => {
throw error;
}
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
const profileStatusSource = new GatewayHttpProfileStatusSource(
config.gatewayInternalApiUrl,
config.gameTokenSecret
);
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const accountIconResetReconciler = new AccountIconResetReconciler(
@@ -214,6 +219,7 @@ export const createGameApiServer = async () => {
flushStore,
gameTokenSecret: config.gameTokenSecret,
accountIconSource,
profileStatusSource,
});
},
},
+107 -5
View File
@@ -1,4 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
@@ -56,6 +56,7 @@ export const generalAccessEndpointWeights = {
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'turns.getCommandTable': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
@@ -65,6 +66,40 @@ export const generalAccessEndpointWeights = {
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
export const generalAccessLimitPages = new Set<AccessPage>(['nation-list', 'npc-control']);
export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
'world.getGeneralDirectory',
'tournament.getSnapshot',
'nation.getSecretGeneralList',
'nation.getGeneralList',
'nation.getStratFinan',
'nation.getBattleCenter',
'nation.getChiefCenter',
'board.getArticles',
'board.writeArticle',
'board.writeComment',
'diplomacy.getLetters',
'diplomacy.sendLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.destroyLetter',
'betting.getList',
'general.getFrontStatus',
'yearbook.getHistory',
'messages.send',
'turns.getCommandTable',
]);
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = {
refreshScore: number;
refreshLimit: number;
level: AccessLimitLevel;
nextAccessAt: Date;
};
export const resolveGeneralAccessEndpointWeight = (
path: string,
input: unknown,
@@ -114,6 +149,58 @@ export const resolveAccessWindows = (
return { periodStartedAt: scoreStartedAt, scoreStartedAt };
};
export const resolveGeneralScoreStartedAt = (tickSeconds: number, nextTurnAt: Date): Date =>
new Date(nextTurnAt.getTime() - Math.max(1, Math.floor(tickSeconds)) * 1_000);
const formatAccessTime = (value: Date): string => {
const kst = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
return kst.toISOString().slice(0, 19).replace('T', ' ');
};
export const formatGeneralAccessLimitMessage = (state: Pick<GeneralAccessState, 'nextAccessAt'>): string =>
`접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ` +
`(다음 접속 가능 시각: ${formatAccessTime(state.nextAccessAt)}) ` +
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.';
export const getGeneralAccessState = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>
): Promise<GeneralAccessState | null> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return null;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { tickSeconds: true, meta: true },
}),
]);
if (!general || !worldState) {
return null;
}
const access = await ctx.db.generalAccessLog.findUnique({
where: { generalId: general.id },
select: { lastRefresh: true, refreshScore: true },
});
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
const refreshScore =
access?.lastRefresh && access.lastRefresh.getTime() < scoreStartedAt.getTime()
? 0
: (access?.refreshScore ?? 0);
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
return {
refreshScore,
refreshLimit,
level: resolveAccessLimitLevel(refreshScore, refreshLimit),
nextAccessAt: general.turnTime,
};
};
export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$transaction'>,
input: {
@@ -286,13 +373,13 @@ export const upsertGeneralAccess = async (
};
export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
weight: number,
now = new Date()
): Promise<boolean> => {
@@ -304,11 +391,25 @@ export const recordGeneralAccessWeight = async (
return false;
}
const profileStatusSource = ctx.profileStatusSource;
if (!profileStatusSource) {
return false;
}
try {
if ((await profileStatusSource.get(ctx.profile.name)) !== 'RUNNING') {
return false;
}
} catch {
// 상태를 확인하지 못한 요청으로 사용자를 벌주지 않는다. 업무 요청은
// 계속 진행하고 다음 요청에서 gateway 상태를 다시 확인한다.
return false;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
select: { id: true, userId: true, turnTime: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
@@ -332,7 +433,8 @@ export const recordGeneralAccessWeight = async (
return false;
}
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
const { periodStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
await upsertGeneralAccess(ctx.db, {
worldStateId: worldState.id,
+47 -1
View File
@@ -5,7 +5,15 @@ 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';
import {
formatGeneralAccessLimitMessage,
generalAccessLimitBeforeRecordEndpoints,
generalAccessLimitEndpoints,
getGeneralAccessState,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
type GeneralAccessEndpoint,
} from './services/generalAccess.js';
const t = initTRPC.context<GameApiContext>().create();
@@ -84,7 +92,40 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
if (weight === null) {
return next();
}
const endpoint = path as GeneralAccessEndpoint;
if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
await recordGeneralAccessWeight(ctx, weight);
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
return next();
});
const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
if (ctx.generalAccessTracking !== true) {
return next();
}
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
return next();
});
@@ -116,6 +157,9 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessLimitMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
@@ -126,3 +170,5 @@ 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);
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware);
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean) => {
},
city: { findUnique: async () => null },
nation: { findUnique: async () => null },
generalAccessLog: { findUnique: async () => null },
worldState: { findFirst: async () => ({ config: { const: {} } }) },
},
} as unknown as GameApiContext;
@@ -379,6 +379,7 @@ integration('general access tracking persistence', () => {
name: yearbookProfile,
scenario: 'default',
},
profileStatusSource: { get: async () => 'RUNNING' as const },
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
+92 -16
View File
@@ -5,16 +5,28 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
import {
accessPageWeights,
generalAccessEndpointWeights,
getGeneralAccessState,
recordGeneralAccess,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
resolveAccessWindows,
resolveGeneralScoreStartedAt,
} from '../src/services/generalAccess.js';
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) };
const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({
auth: token,
db,
profile,
profileStatusSource,
generalAccessTracking: true as const,
});
const auth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
@@ -30,7 +42,10 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
sanctions: {},
});
const buildDb = (meta: Record<string, unknown> = {}) => {
const buildDb = (
meta: Record<string, unknown> = {},
access: { lastRefresh: Date | null; refreshScore: number } | null = null
) => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
const transaction = vi.fn(
@@ -38,7 +53,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
);
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
const findGeneral = vi.fn(async () => ({
id: 7,
userId: 'user-7',
turnTime: new Date('2026-07-26T03:10:00.000Z'),
}));
const findWorld = vi.fn(async () => ({
id: 3,
currentYear: 185,
@@ -53,6 +72,7 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
const db = {
$transaction: transaction,
general: { findFirst: findGeneral },
generalAccessLog: { findUnique: vi.fn(async () => access) },
worldState: { findFirst: findWorld },
} as unknown as DatabaseClient;
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
@@ -91,6 +111,7 @@ describe('general access tracking', () => {
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'turns.getCommandTable': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
@@ -120,17 +141,20 @@ describe('general access tracking', () => {
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
});
expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual(
new Date('2026-07-26T03:10:00.000Z')
);
});
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
const now = new Date('2026-07-26T03:05:00.000Z');
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true);
expect(findGeneral).toHaveBeenCalledWith({
where: { userId: 'user-7' },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
select: { id: true, userId: true, turnTime: true },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
@@ -167,7 +191,7 @@ describe('general access tracking', () => {
const { db, executeRaw, queryRaw, transaction } = buildDb();
const now = new Date('2026-07-26T03:06:00.000Z');
await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true);
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
@@ -175,14 +199,42 @@ describe('general access tracking', () => {
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
});
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
const blocked = buildDb(
{ refreshLimit: 120 },
{ lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 }
);
await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({
refreshScore: 121,
refreshLimit: 120,
level: 2,
nextAccessAt: new Date('2026-07-26T03:10:00.000Z'),
});
const stale = buildDb(
{ refreshLimit: 120 },
{ lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 }
);
await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({
refreshScore: 0,
level: 0,
});
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) });
await expect(
limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read()
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'),
});
expect(resolver).not.toHaveBeenCalled();
});
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
const fixture = buildDb();
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
RangeError
);
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf(
RangeError
);
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError);
expect(fixture.findGeneral).not.toHaveBeenCalled();
});
@@ -201,6 +253,13 @@ describe('general access tracking', () => {
findFirst: vi.fn(async () => ({
id: 7,
userId: 'user-7',
turnTime: new Date('2026-07-26T03:10:00.000Z'),
})),
},
generalAccessLog: {
findUnique: vi.fn(async () => ({
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
refreshScore: 1,
})),
},
worldState: {
@@ -253,6 +312,7 @@ describe('general access tracking', () => {
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
profileStatusSource,
} as unknown as GameApiContext;
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
@@ -279,21 +339,37 @@ describe('general access tracking', () => {
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
const anonymous = buildDb();
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false);
expect(anonymous.findGeneral).not.toHaveBeenCalled();
const admin = buildDb();
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false);
expect(admin.findGeneral).not.toHaveBeenCalled();
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
await expect(
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z'))
).resolves.toBe(false);
expect(future.transaction).not.toHaveBeenCalled();
const united = buildDb({ isUnited: 2 });
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false);
expect(united.transaction).not.toHaveBeenCalled();
const paused = buildDb();
const pausedContext = {
...accessContext(paused.db),
profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) },
};
await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false);
expect(paused.findGeneral).not.toHaveBeenCalled();
const unavailable = buildDb();
const unavailableContext = {
...accessContext(unavailable.db),
profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) },
};
await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false);
expect(unavailable.findGeneral).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GatewayHttpProfileStatusSource } from '../src/auth/profileStatusSource.js';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('GatewayHttpProfileStatusSource', () => {
it('uses an encoded path and a purpose-derived credential', async () => {
const fetchMock = vi.fn<(input: string | URL, init?: RequestInit) => Promise<Response>>(
async () =>
new Response(JSON.stringify({ profileName: 'che:default/한글', status: 'RUNNING' }), { status: 200 })
);
vi.stubGlobal('fetch', fetchMock);
const source = new GatewayHttpProfileStatusSource('http://gateway.internal/', 'root-secret');
await expect(source.get('che:default/한글')).resolves.toBe('RUNNING');
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://gateway.internal/internal/profile-status/che%3Adefault%2F%ED%95%9C%EA%B8%80');
expect(init?.headers).toMatchObject({
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
});
});
it('returns null only for a missing profile and rejects malformed status values', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 404 }))
);
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ profileName: 'che', status: 'UNKNOWN' }), { status: 200 }))
);
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('che')).rejects.toThrow(
'invalid'
);
});
});