feat: 프로필별 플레이 감사 조회와 권한 검사 연결

This commit is contained in:
2026-09-16 02:52:52 +00:00
parent 35155e6b59
commit ed2597aef3
12 changed files with 718 additions and 1 deletions
+2
View File
@@ -1,3 +1,4 @@
import { playAuditRouter } from './router/playAudit/index.js';
import { router } from './trpc.js';
import { battleRouter } from './router/battle/index.js';
@@ -28,6 +29,7 @@ import { archiveRouter } from './router/archive/index.js';
import { dashboardRouter } from './router/dashboard/index.js';
export const appRouter = router({
playAudit: playAuditRouter,
health: healthRouter,
auth: authRouter,
lobby: lobbyRouter,
+182
View File
@@ -0,0 +1,182 @@
import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js';
import {
auditProcedure,
findAuditMonth,
pageResult,
readAudit,
readAuditWorld,
zAuditPage,
zAuditMonth,
} from './shared.js';
import {
citySelect,
generalSelect,
projectCurrentCity,
projectCurrentGeneral,
zAuditCityData,
zAuditGeneralData,
} from './projection.js';
export const playAuditRouter = router({
capabilities: auditProcedure.query(({ ctx }) => ({
profileName: ctx.profile.name,
read: true,
accounts: canReadPlayAuditAccounts(ctx.auth!.user.roles, ctx.profile.name),
})),
coverage: auditProcedure
.input(
z
.object({ cursor: zAuditMonth.optional(), limit: z.number().int().min(1).max(200).default(50) })
.strict()
.default({ limit: 50 })
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const samples = world.serverId
? await tx.playAuditMonth.findMany({
where: {
serverId: world.serverId,
...(input.cursor
? {
OR: [
{ year: { gt: input.cursor.year } },
{ year: input.cursor.year, month: { gt: input.cursor.month } },
{
year: input.cursor.year,
month: input.cursor.month,
kind: { gt: input.cursor.kind },
},
],
}
: {}),
},
take: input.limit + 1,
orderBy: [{ year: 'asc' }, { month: 'asc' }, { kind: 'asc' }],
select: { year: true, month: true, kind: true, settlementsComplete: true, createdAt: true },
})
: [];
return {
...world,
status: !world.serverId
? ('IDENTITY_MISSING' as const)
: samples.length
? ('COLLECTED' as const)
: input.cursor
? ('PAGE_EMPTY' as const)
: ('NOT_COLLECTED' as const),
samples: samples.slice(0, input.limit),
nextCursor:
samples.length > input.limit
? {
year: samples[input.limit - 1]!.year,
month: samples[input.limit - 1]!.month,
kind: z.enum(['MONTH_END', 'FINAL']).parse(samples[input.limit - 1]!.kind),
}
: null,
};
})
),
generals: auditProcedure
.input(
zAuditPage.extend({
cityId: z.number().int().nonnegative().optional(),
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
})
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const npcState =
input.population === 'human'
? { lt: 2 }
: input.population === 'npc'
? { gte: 2, not: 5 }
: input.population === 'troopNpc'
? 5
: undefined;
const filter = { nationId: input.nationId, cityId: input.cityId, npcState };
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const rows = sample
? await tx.playAuditGeneral.findMany({
where: {
sampleId: sample.id,
...filter,
generalId: input.cursor === undefined ? undefined : { gt: input.cursor },
},
orderBy: { generalId: 'asc' },
take: input.limit + 1,
select: { data: true },
})
: [];
return {
...world,
sample,
collected: Boolean(sample),
...pageResult(
rows.map((row) => zAuditGeneralData.parse(row.data)),
input.limit,
(row) => row.id
),
};
}
const rows = await tx.general.findMany({
where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
orderBy: { id: 'asc' },
take: input.limit + 1,
select: generalSelect,
});
return {
...world,
sample: null,
collected: true,
...pageResult(rows.map(projectCurrentGeneral), input.limit, (row) => row.id),
};
})
),
cities: auditProcedure.input(zAuditPage).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const rows = sample
? await tx.playAuditCity.findMany({
where: {
sampleId: sample.id,
nationId: input.nationId,
cityId: input.cursor === undefined ? undefined : { gt: input.cursor },
},
orderBy: { cityId: 'asc' },
take: input.limit + 1,
select: { data: true },
})
: [];
return {
...world,
sample,
collected: Boolean(sample),
...pageResult(
rows.map((row) => zAuditCityData.parse(row.data)),
input.limit,
(row) => row.id
),
};
}
const rows = await tx.city.findMany({
where: { nationId: input.nationId, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
orderBy: { id: 'asc' },
take: input.limit + 1,
select: citySelect,
});
return {
...world,
sample: null,
collected: true,
...pageResult(rows.map(projectCurrentCity), input.limit, (row) => row.id),
};
})
),
});
@@ -0,0 +1,140 @@
import { z } from 'zod';
import { asNumber, asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
const dex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
export const zAuditGeneralData = z.object({
id: z.number(),
name: z.string(),
userId: z.string().nullable(),
nationId: z.number(),
cityId: z.number(),
troopId: z.number(),
npcState: z.number(),
gold: z.number(),
rice: z.number(),
stats: z.object({ leadership: z.number(), strength: z.number(), intelligence: z.number() }),
experience: z.number(),
dedication: z.number(),
officerLevel: z.number(),
injury: z.number(),
age: z.number(),
crew: z.number(),
crewTypeId: z.number(),
train: z.number(),
atmos: z.number(),
dex,
role: z.object({
personality: z.string().nullable(),
specialDomestic: z.string().nullable(),
specialWar: z.string().nullable(),
items: z.object({
horse: z.string().nullable(),
weapon: z.string().nullable(),
book: z.string().nullable(),
item: z.string().nullable(),
}),
}),
});
export const zAuditCityData = z.object({
id: z.number(),
name: z.string(),
nationId: z.number(),
level: z.number(),
state: z.number(),
population: z.number(),
populationMax: z.number(),
agriculture: z.number(),
agricultureMax: z.number(),
commerce: z.number(),
commerceMax: z.number(),
security: z.number(),
securityMax: z.number(),
wall: z.number(),
wallMax: z.number(),
defence: z.number(),
defenceMax: z.number(),
supplyState: z.number(),
frontState: z.number(),
trust: z.number(),
});
export const generalSelect = {
id: true,
name: true,
userId: true,
nationId: true,
cityId: true,
troopId: true,
npcState: true,
gold: true,
rice: true,
leadership: true,
strength: true,
intel: true,
experience: true,
dedication: true,
officerLevel: true,
injury: true,
age: true,
crew: true,
crewTypeId: true,
train: true,
atmos: true,
personalCode: true,
specialCode: true,
special2Code: true,
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
meta: true,
} satisfies GamePrisma.GeneralSelect;
export const citySelect = {
id: true,
name: true,
nationId: true,
level: true,
population: true,
populationMax: true,
agriculture: true,
agricultureMax: true,
commerce: true,
commerceMax: true,
security: true,
securityMax: true,
wall: true,
wallMax: true,
defence: true,
defenceMax: true,
supplyState: true,
frontState: true,
trust: true,
meta: true,
} satisfies GamePrisma.CitySelect;
const code = (value: string): string | null => (value === 'None' ? null : value);
export const projectCurrentGeneral = (
row: GamePrisma.GeneralGetPayload<{ select: typeof generalSelect }>
): z.infer<typeof zAuditGeneralData> => {
const meta = asRecord(row.meta);
return zAuditGeneralData.parse({
...row,
stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel },
dex: Object.fromEntries(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'].map((key) => [key, asNumber(meta[key], 0)])),
role: {
personality: code(row.personalCode),
specialDomestic: code(row.specialCode),
specialWar: code(row.special2Code),
items: {
horse: code(row.horseCode),
weapon: code(row.weaponCode),
book: code(row.bookCode),
item: code(row.itemCode),
},
},
});
};
export const projectCurrentCity = (
row: GamePrisma.CityGetPayload<{ select: typeof citySelect }>
): z.infer<typeof zAuditCityData> =>
zAuditCityData.parse({ ...row, state: Math.floor(asNumber(asRecord(row.meta).state, 0)) });
+124
View File
@@ -0,0 +1,124 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord, canReadPlayAudit } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { readOnlyAuthedProcedure } from '../../trpc.js';
export const auditProcedure = readOnlyAuthedProcedure.use(({ ctx, next }) => {
if (
!ctx.auth ||
ctx.auth.profile !== ctx.profile.name ||
!canReadPlayAudit(ctx.auth.user.roles, ctx.profile.name)
) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이 프로필의 플레이 감사 권한이 필요합니다.' });
}
return next();
});
export const zAuditMonth = z
.object({
year: z.number().int().min(0).max(9999),
month: z.number().int().min(1).max(12),
kind: z.enum(['MONTH_END', 'FINAL']).default('MONTH_END'),
})
.strict();
export const zAuditPage = z
.object({
at: zAuditMonth.optional(),
nationId: z.number().int().nonnegative().optional(),
cursor: z.number().int().nonnegative().optional(),
limit: z.number().int().min(1).max(200).default(50),
})
.strict();
export const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1;
export const readAudit = async <T>(
ctx: GameApiContext,
read: (tx: GamePrisma.TransactionClient) => Promise<T>
): Promise<T> => {
if (!ctx.db.$transaction)
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '감사 조회 transaction을 사용할 수 없습니다.' });
try {
return await ctx.db.$transaction(read, { isolationLevel: 'RepeatableRead', maxWait: 2000, timeout: 5000 });
} catch (error) {
if (error instanceof TRPCError) throw error;
if (error && typeof error === 'object' && 'code' in error && ['P2028', 'P2034'].includes(String(error.code))) {
throw new TRPCError({ code: 'TIMEOUT', message: '조회가 지연되었습니다. 기간을 줄여 다시 조회해 주세요.' });
}
throw error;
}
};
export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
const world = await tx.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
currentYear: true,
currentMonth: true,
lastTurnTick: true,
meta: true,
config: true,
},
});
if (!world) throw new TRPCError({ code: 'NOT_FOUND', message: '게임 상태가 없습니다.' });
const meta = asRecord(world.meta);
const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId : null;
const scenario = asRecord(meta.scenarioMeta);
const config = asRecord(world.config);
const startYear =
typeof scenario.startYear === 'number'
? scenario.startYear
: typeof asRecord(config.scenarioMeta).startYear === 'number'
? Number(asRecord(config.scenarioMeta).startYear)
: world.currentYear;
return {
serverId,
year: world.currentYear,
month: world.currentMonth,
startYear,
tick: world.lastTurnTick?.toString() ?? null,
asOf: new Date().toISOString(),
};
};
export type AuditWorld = Awaited<ReturnType<typeof readAuditWorld>>;
export const findAuditMonth = async (
tx: GamePrisma.TransactionClient,
world: AuditWorld,
at: z.infer<typeof zAuditMonth>
) => {
const ordinal = monthOrdinal(at.year, at.month);
if (at.year < world.startYear || ordinal > monthOrdinal(world.year, world.month)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' });
}
if (!world.serverId) return null;
return tx.playAuditMonth.findUnique({
where: {
serverId_year_month_kind: {
serverId: world.serverId,
year: at.year,
month: at.month,
kind: at.kind,
},
},
select: {
id: true,
year: true,
month: true,
kind: true,
tick: true,
settlementsComplete: true,
createdAt: true,
},
});
};
export const pageResult = <T>(
rows: T[],
limit: number,
getId: (row: T) => number
): { items: T[]; nextCursor: number | null } => {
const items = rows.slice(0, limit);
return { items, nextCursor: rows.length > limit ? getId(items[items.length - 1]!) : null };
};
@@ -1,3 +1,4 @@
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import fs from 'node:fs/promises';
import { createServer, type Server as HttpServer } from 'node:http';
import os from 'node:os';
@@ -8,6 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
GamePrisma,
createRedisConnector,
enqueueWebPushOutboxEvents,
resolveRedisConfigFromEnv,
@@ -2147,6 +2149,135 @@ integration('game API security over HTTP transport', () => {
});
}, 10_000);
it('play audit HTTP scope, no-general access, bounded history and token revocation', async () => {
const auditUserId = `audit-http-${process.pid}`;
const seasonId = `audit-season-${process.pid}`;
const sampleId = `${seasonId}:190:1`;
const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } });
const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => {
const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId);
payload.user.roles = roles;
const issued = await accessTokenStore.create(payload);
if (!issued) throw new Error('audit token fixture failed');
return issued.accessToken;
};
const get = async (path: string, accessToken?: string, input?: unknown) => {
const response = await fetch(
`${baseUrl}/trpc/playAudit.${path}${input === undefined ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`,
{
headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {},
}
);
return { status: response.status, body: (await response.json()) as unknown };
};
try {
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
currentYear: 190,
currentMonth: 2,
meta: { serverId: seasonId, scenarioMeta: { startYear: 190 } },
},
});
const current = await db.general.findUniqueOrThrow({ where: { id: generalId } });
const past = projectCurrentGeneral(current);
await db.playAuditMonth.create({
data: {
id: sampleId,
serverId: seasonId,
year: 190,
month: 1,
kind: 'MONTH_END',
settlementsComplete: true,
hash: 'http-fixture',
generals: {
create: {
generalId,
nationId: current.nationId,
cityId: current.cityId,
npcState: current.npcState,
data: { ...past, name: '과거이름', hiddenSecret: 'must-not-expose' },
},
},
},
});
const beforeInputs = await db.inputEvent.count();
expect((await get('capabilities')).status).toBe(401);
for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) {
expect((await get('capabilities', await token(roles))).status).toBe(403);
}
const admin = await token([`admin.playAudit.read:${profileName}`]);
expect(await db.general.findUnique({ where: { userId: auditUserId } })).toBeNull();
expect((await get('capabilities', admin)).body).toMatchObject({
result: { data: { read: true, accounts: false } },
});
const first = await get('generals', admin, { limit: 1 });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({
result: { data: { nextCursor: generalId, items: [{ id: generalId }] } },
});
expect((await get('generals', admin, { limit: 1, cursor: generalId })).body).toMatchObject({
result: { data: { items: [{ id: sameNationGeneralId }] } },
});
expect((await get('generals', admin, { population: 'npc' })).body).toMatchObject({
result: { data: { items: [{ id: npcGeneralId }] } },
});
expect((await get('coverage', admin, { limit: 1 })).body).toMatchObject({
result: { data: { status: 'COLLECTED', samples: [{ year: 190, month: 1 }] } },
});
expect((await get('coverage', admin, { cursor: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { status: 'PAGE_EMPTY', samples: [] } },
});
expect((await get('generals', admin, { limit: 201 })).status).toBe(400);
expect((await get('generals', admin, { at: { year: 191, month: 1 } })).status).toBe(400);
const history = await get('generals', admin, { at: { year: 190, month: 1 } });
expect(history.status).toBe(200);
expect(history.body).toMatchObject({
result: { data: { collected: true, items: [{ name: '과거이름' }] } },
});
expect(JSON.stringify(history.body)).not.toContain('must-not-expose');
expect((await get('generals', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
const blocked = await token([`admin.playAudit.read:${profileName}`], {
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
});
expect((await get('capabilities', blocked)).status).toBe(403);
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
meta: {
serverId: `${seasonId}:new`,
scenarioMeta: { startYear: 190 },
},
},
});
expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
expect(await db.inputEvent.count()).toBe(beforeInputs);
await redis!.client.publish(
`${redisPrefix}:flush`,
JSON.stringify({
userId: auditUserId,
flushedAt: new Date().toISOString(),
reason: 'audit-role-revoked',
})
);
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally {
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
meta: originalWorld.meta ?? GamePrisma.JsonNull,
currentYear: originalWorld.currentYear,
currentMonth: originalWorld.currentMonth,
},
});
}
});
// Flush invalidates every token issued before the user watermark. Keep it
// last so this lifecycle assertion cannot invalidate the actor tokens used
// by the transport authorization matrix above.