merge: Ref 호환 접속 제한과 벌점 초기화를 반영
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -914,6 +914,7 @@ export const createDatabaseTurnHooks = async (
|
||||
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
|
||||
let visibleLogFloor = directLogFloor;
|
||||
const {
|
||||
accessScoreResetGeneralIds,
|
||||
generals,
|
||||
cities,
|
||||
nations,
|
||||
@@ -1012,6 +1013,13 @@ export const createDatabaseTurnHooks = async (
|
||||
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
|
||||
);
|
||||
|
||||
if (accessScoreResetGeneralIds.length > 0) {
|
||||
await prisma.generalAccessLog.updateMany({
|
||||
where: { generalId: { in: accessScoreResetGeneralIds } },
|
||||
data: { refreshScore: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
if (inheritancePointAdjustments.length > 0) {
|
||||
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
||||
for (const entry of inheritancePointAdjustments) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from
|
||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, calculateAccessRefreshLimit } from '@sammo-ts/common';
|
||||
|
||||
export interface InMemoryTurnProcessorOptions {
|
||||
tickMinutes?: number;
|
||||
@@ -50,6 +50,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
||||
|
||||
this.world.setCheckpoint(checkpoint);
|
||||
this.world.updateWorldMeta({
|
||||
refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds),
|
||||
});
|
||||
|
||||
let processedGenerals = 0;
|
||||
let processedTurns = 0;
|
||||
@@ -97,6 +100,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
if (executionError !== undefined) {
|
||||
throw executionError;
|
||||
}
|
||||
// Ref의 updateTurnTime()은 장수 명령이 성공한 뒤 그 장수의
|
||||
// 순간 벌점을 같은 턴 flush에서 초기화한다.
|
||||
this.world.markGeneralAccessScoreReset(general.id);
|
||||
processedGenerals += 1;
|
||||
nextCheckpoint = {
|
||||
turnTime: executedAt.toISOString(),
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface InMemoryGameClockState {
|
||||
}
|
||||
|
||||
export interface TurnWorldChanges {
|
||||
accessScoreResetGeneralIds: number[];
|
||||
generals: TurnGeneral[];
|
||||
cities: City[];
|
||||
nations: Nation[];
|
||||
@@ -156,6 +157,7 @@ export interface InMemoryTurnWorldStateSnapshot {
|
||||
dirtyNationIds: number[];
|
||||
dirtyTroopIds: number[];
|
||||
dirtyDiplomacyKeys: string[];
|
||||
accessScoreResetGeneralIds: number[];
|
||||
createdGeneralIds: number[];
|
||||
createdNationIds: number[];
|
||||
createdTroopIds: number[];
|
||||
@@ -419,6 +421,7 @@ export class InMemoryTurnWorld {
|
||||
private readonly dirtyNationIds = new Set<number>();
|
||||
private readonly dirtyTroopIds = new Set<number>();
|
||||
private readonly dirtyDiplomacyKeys = new Set<string>();
|
||||
private readonly accessScoreResetGeneralIds = new Set<number>();
|
||||
private readonly createdGeneralIds = new Set<number>();
|
||||
private nextLegacyGeneralScanOrder = 0;
|
||||
private readonly createdNationIds = new Set<number>();
|
||||
@@ -606,6 +609,7 @@ export class InMemoryTurnWorld {
|
||||
dirtyNationIds: Array.from(this.dirtyNationIds),
|
||||
dirtyTroopIds: Array.from(this.dirtyTroopIds),
|
||||
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
|
||||
accessScoreResetGeneralIds: Array.from(this.accessScoreResetGeneralIds),
|
||||
createdGeneralIds: Array.from(this.createdGeneralIds),
|
||||
createdNationIds: Array.from(this.createdNationIds),
|
||||
createdTroopIds: Array.from(this.createdTroopIds),
|
||||
@@ -644,6 +648,7 @@ export class InMemoryTurnWorld {
|
||||
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
|
||||
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
|
||||
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
|
||||
this.replaceSet(this.accessScoreResetGeneralIds, restored.accessScoreResetGeneralIds ?? []);
|
||||
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
|
||||
this.replaceSet(this.createdNationIds, restored.createdNationIds);
|
||||
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
|
||||
@@ -693,6 +698,12 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
}
|
||||
|
||||
markGeneralAccessScoreReset(generalId: number): void {
|
||||
if (Number.isSafeInteger(generalId) && generalId > 0) {
|
||||
this.accessScoreResetGeneralIds.add(generalId);
|
||||
}
|
||||
}
|
||||
|
||||
changeTurnTerm(tickMinutes: number): void {
|
||||
if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) {
|
||||
throw new Error('Turn term must be a positive integer.');
|
||||
@@ -1512,8 +1523,12 @@ export class InMemoryTurnWorld {
|
||||
}));
|
||||
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
|
||||
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
|
||||
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
|
||||
(left, right) => left - right
|
||||
);
|
||||
|
||||
return {
|
||||
accessScoreResetGeneralIds,
|
||||
generals,
|
||||
cities,
|
||||
nations,
|
||||
@@ -1542,6 +1557,7 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
acknowledgeDirtyState(changes: TurnWorldChanges): void {
|
||||
for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id);
|
||||
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
|
||||
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
|
||||
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 991_815;
|
||||
const scenarioCode = 'general-access-score-reset-persistence';
|
||||
|
||||
integration('general access score reset persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
const cleanup = async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('commits the own-turn reset marker in the same world flush', async () => {
|
||||
const turnTime = new Date('2026-08-15T00:10:00.000Z');
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId: 'access-reset-persistence-user',
|
||||
name: '접속점수초기화장수',
|
||||
turnTime,
|
||||
},
|
||||
});
|
||||
await db.generalAccessLog.create({
|
||||
data: {
|
||||
generalId,
|
||||
userId: 'access-reset-persistence-user',
|
||||
lastRefresh: new Date('2026-08-15T00:09:59.000Z'),
|
||||
refresh: 120,
|
||||
refreshTotal: 500,
|
||||
refreshScore: 351,
|
||||
refreshScoreTotal: 999,
|
||||
},
|
||||
});
|
||||
const row = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
const state: TurnWorldState = {
|
||||
id: row.id,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||
meta: {},
|
||||
};
|
||||
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
};
|
||||
const world = new InMemoryTurnWorld(
|
||||
state,
|
||||
{
|
||||
scenarioConfig,
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
},
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
world.markGeneralAccessScoreReset(generalId);
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
|
||||
try {
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
refresh: 120,
|
||||
refreshTotal: 500,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 999,
|
||||
});
|
||||
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]);
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,8 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
||||
expect(tiedGeneralResult.processedTurns).toBe(0);
|
||||
expect(executed).toEqual([3, 2]);
|
||||
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([2, 3]);
|
||||
expect(world.getState().meta).toMatchObject({ refreshLimit: 350 });
|
||||
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
|
||||
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
|
||||
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
|
||||
|
||||
@@ -3,6 +3,13 @@ import { resolve } from 'node:path';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32029,
|
||||
data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
|
||||
},
|
||||
});
|
||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
@@ -33,6 +40,7 @@ type NavigationFixture = {
|
||||
commandBlockedCount?: number;
|
||||
forceSnapshotCalls?: number;
|
||||
refreshDelayMs?: number;
|
||||
accessLimitAfterCalls?: number;
|
||||
largeCommandTable?: boolean;
|
||||
refCommandCategories?: boolean;
|
||||
currentYear?: number;
|
||||
@@ -372,6 +380,14 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
state.generalMeCalls += 1;
|
||||
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
|
||||
return errorResponse(
|
||||
operation,
|
||||
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
|
||||
'(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
|
||||
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
|
||||
);
|
||||
}
|
||||
const input = operationInput(route, index);
|
||||
const include = input.include ?? {};
|
||||
const forceSnapshot = input.forceSnapshot === true;
|
||||
@@ -503,7 +519,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
operations.forEach((operation, index) => {
|
||||
if (operation !== 'dashboard.getContextBundleDelta') return;
|
||||
const item = results[index];
|
||||
if (!item) return;
|
||||
if (!item || !('result' in item)) return;
|
||||
const data = item.result.data as {
|
||||
context?: { kind: string };
|
||||
commandTable?: { kind: string };
|
||||
@@ -2060,7 +2076,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
});
|
||||
await expect
|
||||
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
|
||||
.toEqual(['general.getFrontStatus']);
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
|
||||
|
||||
const profile = await page.evaluate(() => {
|
||||
const probe = (
|
||||
@@ -2218,6 +2234,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
|
||||
});
|
||||
|
||||
test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
accessLimitAfterCalls: 1,
|
||||
};
|
||||
await installRealtimeHarness(page);
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await waitForMain(page);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const operationsBeforeLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(false);
|
||||
expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);
|
||||
|
||||
const operationsAfterLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(state.operations).toHaveLength(operationsAfterLimit);
|
||||
|
||||
state.accessLimitAfterCalls = undefined;
|
||||
await page.getByRole('button', { name: '갱 신' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
@@ -2245,7 +2310,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
const operationsBeforeGlobal = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
|
||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
|
||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
|
||||
'dashboard.getContextBundleDelta',
|
||||
'general.getRecentRecords',
|
||||
]);
|
||||
|
||||
state.worldHistory = [
|
||||
{ id: 5, text: '자동 갱신된 중원 정세' },
|
||||
@@ -2254,7 +2322,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
const operationsBeforeHistory = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual([
|
||||
'dashboard.getContextBundleDelta',
|
||||
'general.getRecentRecords',
|
||||
]);
|
||||
|
||||
state.currentMonth = 2;
|
||||
const operationsBeforeMonth = state.operations.length;
|
||||
|
||||
@@ -82,6 +82,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const realtimeEnabled = ref(true);
|
||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||
const realtimeActive = ref(false);
|
||||
const accessLimited = ref(false);
|
||||
|
||||
const handleDashboardError = (value: unknown) => {
|
||||
const message = resolveErrorMessage(value);
|
||||
error.value = message;
|
||||
if (message.startsWith('접속 제한중입니다.')) {
|
||||
accessLimited.value = true;
|
||||
realtimeStatus.value = 'paused';
|
||||
}
|
||||
};
|
||||
|
||||
const general = ref<PresentGeneralContext['general'] | null>(null);
|
||||
const city = ref<PresentGeneralContext['city'] | null>(null);
|
||||
@@ -508,6 +518,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
{ context: true, commandTable: true, boardAccess: true },
|
||||
true
|
||||
);
|
||||
accessLimited.value = false;
|
||||
applyDashboardPatch(contextPatch);
|
||||
const context = contextSnapshot;
|
||||
|
||||
@@ -573,7 +584,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
loading.value = false;
|
||||
@@ -626,14 +637,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (plan.records) recordsError.value = null;
|
||||
if (plan.frontStatus) frontStatusError.value = null;
|
||||
try {
|
||||
const contextBundlePromise =
|
||||
plan.context || plan.commands || plan.boardAccess
|
||||
? fetchContextBundlePatch({
|
||||
context: plan.context,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const contextPatch = await fetchContextBundlePatch({
|
||||
// Every automatic refresh crosses this access-limit gate. The
|
||||
// context delta is usually unchanged and therefore stays small.
|
||||
context: true,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
});
|
||||
accessLimited.value = false;
|
||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||
const mapPromise = plan.map
|
||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
||||
@@ -659,8 +670,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||
contextBundlePromise,
|
||||
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||
lobbyPromise,
|
||||
mapPromise,
|
||||
contactsPromise,
|
||||
@@ -669,7 +679,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
frontPromise,
|
||||
]);
|
||||
|
||||
const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {};
|
||||
const patch: DashboardReadModelPatch = { ...contextPatch };
|
||||
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
||||
if (map !== undefined) patch.worldMap = map;
|
||||
if (contacts !== undefined) patch.messageContacts = contacts;
|
||||
@@ -690,7 +700,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
@@ -709,7 +719,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -970,6 +980,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeActive.value &&
|
||||
document.visibilityState !== 'hidden' &&
|
||||
realtimeEnabled.value &&
|
||||
!accessLimited.value &&
|
||||
session.isReady &&
|
||||
session.hasGeneral &&
|
||||
generalId.value !== null;
|
||||
@@ -1158,11 +1169,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
session.profile,
|
||||
session.user?.id,
|
||||
generalId.value,
|
||||
accessLimited.value,
|
||||
],
|
||||
([active, enabled, ready, hasGeneral]) => {
|
||||
realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value;
|
||||
([active, enabled, ready, hasGeneral, , , , , limited]) => {
|
||||
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
|
||||
if (!active || !ready || !hasGeneral) {
|
||||
realtimeStatus.value = enabled ? 'idle' : 'paused';
|
||||
realtimeStatus.value = enabled && !limited ? 'idle' : 'paused';
|
||||
}
|
||||
reconcileRealtimeCoordinator();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
import type { GatewayProfileRepository } from '../orchestrator/profileRepository.js';
|
||||
|
||||
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
export const registerProfileStatusInternalRoute = (
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
profiles: GatewayProfileRepository;
|
||||
secret: string;
|
||||
}
|
||||
): void => {
|
||||
app.get<{ Params: { profileName: string } }>('/internal/profile-status/:profileName', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const profileName = request.params.profileName;
|
||||
const profile = await options.profiles.getProfile(profileName);
|
||||
if (!profile) {
|
||||
await reply.status(404).send({ ok: false, error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await reply.send({ profileName: profile.profileName, status: profile.status });
|
||||
});
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRep
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
import { registerProfileStatusInternalRoute } from './lobby/profileStatusInternalRoute.js';
|
||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||
@@ -98,6 +99,10 @@ export const createGatewayApiServer = async () => {
|
||||
users,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
registerProfileStatusInternalRoute(app, {
|
||||
profiles,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||
import { registerProfileStatusInternalRoute } from '../src/lobby/profileStatusInternalRoute.js';
|
||||
|
||||
const secret = 'gateway-profile-status-test-secret';
|
||||
const token = createHmac('sha256', secret).update('sammo:profile-status-source:v1').digest('hex');
|
||||
|
||||
describe('profile status internal route', () => {
|
||||
it('requires a purpose-derived token and returns only the durable status', async () => {
|
||||
const app = fastify();
|
||||
const profiles = {
|
||||
getProfile: vi.fn(async (profileName: string) => ({ profileName, status: 'PAUSED' })),
|
||||
} as unknown as GatewayProfileRepository;
|
||||
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/che%3Adefault',
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/che%3Adefault',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({ profileName: 'che:default', status: 'PAUSED' });
|
||||
expect(Object.keys(response.json()).sort()).toEqual(['profileName', 'status']);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown profile', async () => {
|
||||
const app = fastify();
|
||||
const profiles = { getProfile: vi.fn(async () => null) } as unknown as GatewayProfileRepository;
|
||||
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/missing',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export const ACCESS_REFRESH_LIMIT_COEFFICIENT = 10;
|
||||
|
||||
export type AccessLimitLevel = 0 | 1 | 2;
|
||||
|
||||
export const calculateAccessRefreshLimit = (tickSeconds: number): number => {
|
||||
if (!Number.isFinite(tickSeconds) || tickSeconds <= 0) {
|
||||
throw new RangeError('tickSeconds must be a positive finite number.');
|
||||
}
|
||||
const turnMinutes = tickSeconds / 60;
|
||||
return Math.round(Math.pow(turnMinutes, 0.6) * 3) * ACCESS_REFRESH_LIMIT_COEFFICIENT;
|
||||
};
|
||||
|
||||
export const resolveAccessRefreshLimit = (tickSeconds: number, storedLimit: unknown): number => {
|
||||
if (typeof storedLimit === 'number' && Number.isSafeInteger(storedLimit) && storedLimit > 0) {
|
||||
return storedLimit;
|
||||
}
|
||||
return calculateAccessRefreshLimit(tickSeconds);
|
||||
};
|
||||
|
||||
export const resolveAccessLimitLevel = (refreshScore: number, refreshLimit: number): AccessLimitLevel => {
|
||||
if (refreshScore > refreshLimit) {
|
||||
return 2;
|
||||
}
|
||||
if (refreshScore > refreshLimit * 0.9) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -23,3 +23,4 @@ export * from './ranking/legacyColor.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
export * from './logging/formatLegacyLogHtml.js';
|
||||
export * from './gateway/profileStatus.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateAccessRefreshLimit,
|
||||
resolveAccessLimitLevel,
|
||||
resolveAccessRefreshLimit,
|
||||
} from '../src/game/accessPenalty.js';
|
||||
|
||||
describe('legacy access penalty', () => {
|
||||
it.each([
|
||||
[60, 30],
|
||||
[300, 80],
|
||||
[600, 120],
|
||||
[1_200, 180],
|
||||
])('calculates the Ref refresh limit for %i-second turns', (tickSeconds, expected) => {
|
||||
expect(calculateAccessRefreshLimit(tickSeconds)).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps a persisted positive limit and derives a missing one', () => {
|
||||
expect(resolveAccessRefreshLimit(600, 777)).toBe(777);
|
||||
expect(resolveAccessRefreshLimit(600, undefined)).toBe(120);
|
||||
expect(resolveAccessRefreshLimit(600, 0)).toBe(120);
|
||||
});
|
||||
|
||||
it('uses the strict Ref threshold and warns only above ninety percent', () => {
|
||||
expect(resolveAccessLimitLevel(108, 120)).toBe(0);
|
||||
expect(resolveAccessLimitLevel(109, 120)).toBe(1);
|
||||
expect(resolveAccessLimitLevel(120, 120)).toBe(1);
|
||||
expect(resolveAccessLimitLevel(121, 120)).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user