merge: 최신 main의 갱신 비용 변경을 tRPC 전송에 통합

# Conflicts:
#	app/game-api/src/server.ts
#	app/game-frontend/e2e/mainNavigation.spec.ts
#	app/game-frontend/src/utils/trpc.ts
This commit is contained in:
2026-08-17 11:22:19 +00:00
18 changed files with 643 additions and 96 deletions
+50 -1
View File
@@ -4,7 +4,7 @@ import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js';
import { dashboardRouter } from '../src/router/dashboard/index.js';
import { dashboardRouter, requiresDashboardProjection } from '../src/router/dashboard/index.js';
const auth: GameSessionTokenPayload = {
version: 1,
@@ -141,6 +141,55 @@ const contextOnly = {
};
describe('dashboardRouter.getContextBundleDelta', () => {
it('classifies revision-only checks separately from PostgreSQL projection rebuilds', () => {
const sourceRevision = 'S'.repeat(22);
const sourceState = {
coverageVersion: 1,
identity: { generalId: 7, cityId: 0, nationId: 0 },
sourceRevisions: {
context: sourceRevision,
commandTable: 'T'.repeat(22),
boardAccess: 'B'.repeat(22),
},
};
expect(
requiresDashboardProjection({
included: true,
sourceState,
slice: 'context',
knownContent: 'C'.repeat(22),
knownSource: sourceRevision,
})
).toBe(false);
expect(
requiresDashboardProjection({
included: true,
sourceState,
slice: 'context',
knownContent: 'C'.repeat(22),
knownSource: 'X'.repeat(22),
})
).toBe(true);
expect(
requiresDashboardProjection({
included: true,
sourceState,
slice: 'context',
knownContent: 'C'.repeat(22),
knownSource: sourceRevision,
forceSnapshot: true,
})
).toBe(true);
expect(
requiresDashboardProjection({
included: false,
sourceState,
slice: 'context',
})
).toBe(false);
});
it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
const fixture = buildContext(true);
const caller = dashboardRouter.createCaller(fixture.context);
@@ -58,12 +58,25 @@ integration('general access tracking persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
let previousCoverageVersion: number | null;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
previousCoverageVersion =
(
await db.readModelRevisionMeta.findUnique({
where: { id: 1 },
select: { coverageVersion: true },
})
)?.coverageVersion ?? null;
await db.readModelRevisionMeta.upsert({
where: { id: 1 },
create: { id: 1, coverageVersion: 1 },
update: { coverageVersion: 1 },
});
await db.generalAccessLog.deleteMany({
where: {
generalId: {
@@ -126,6 +139,14 @@ integration('general access tracking persistence', () => {
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
if (previousCoverageVersion === null) {
await db.readModelRevisionMeta.deleteMany({ where: { id: 1 } });
} else {
await db.readModelRevisionMeta.update({
where: { id: 1 },
data: { coverageVersion: previousCoverageVersion },
});
}
await closeDb?.();
});
@@ -380,6 +401,10 @@ integration('general access tracking persistence', () => {
scenario: 'default',
},
profileStatusSource: { get: async () => 'RUNNING' as const },
redis: {
get: async () => null,
set: async () => 'OK',
},
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
@@ -387,12 +412,52 @@ integration('general access tracking persistence', () => {
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
const initialDashboard = await dashboardCaller.dashboard.getContextBundleDelta({
include: { context: true, commandTable: false, boardAccess: false },
forceSnapshot: true,
});
expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } });
const initialContext = initialDashboard.context;
if (!initialContext?.sourceRevision) {
throw new Error('dashboard snapshot did not include its post-access source revision');
}
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
await expect(
dashboardCaller.dashboard.getContextBundleDelta({
include: { context: true, commandTable: false, boardAccess: false },
known: { context: initialContext.revision },
knownSource: { context: initialContext.sourceRevision },
})
).resolves.toMatchObject({ context: { kind: 'unchanged' } });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
await db.generalAccessLog.delete({ where: { generalId: endpointGeneralId } });
const realtimeDashboardCaller = appRouter.createCaller({ ...context, realtimeAccessGranted: true });
await expect(
realtimeDashboardCaller.dashboard.getContextBundleDelta({
include: { context: true, commandTable: false, boardAccess: false },
forceSnapshot: true,
})
).resolves.toMatchObject({ context: { kind: 'snapshot' } });
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
const grantedBoundaryCaller = endpointBoundaryRouter.createCaller({
...context,
realtimeAccessGranted: true,
});
await expect(grantedBoundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({
ok: true,
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
+14 -10
View File
@@ -3,9 +3,15 @@ import { describe, expect, it } from 'vitest';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
import {
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
type: 'turnCompleted',
@@ -42,6 +48,7 @@ describe('public realtime event privacy boundary', () => {
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
refreshGrant,
invalidation: {
context: true,
lobby: false,
@@ -99,6 +106,7 @@ describe('public realtime event privacy boundary', () => {
)
).toEqual({
type: 'readModelInvalidated',
refreshGrant,
invalidation: {
context: true,
lobby: true,
@@ -119,6 +127,7 @@ describe('public realtime event privacy boundary', () => {
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
refreshGrant,
invalidation: {
context: false,
lobby: false,
@@ -146,7 +155,7 @@ describe('public realtime event privacy boundary', () => {
senderId: 99,
};
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
@@ -157,13 +166,10 @@ describe('public realtime event privacy boundary', () => {
};
const publicEvent = toPublicRealtimeEvent(event, [viewer]);
expect(publicEvent).toEqual({ type: 'messagesInvalidated' });
expect(publicEvent).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u);
expect(
toPublicRealtimeEvent(
{ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] },
[viewer]
)
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, [viewer])
).toBeNull();
});
@@ -195,9 +201,7 @@ describe('public realtime event privacy boundary', () => {
},
};
expect(
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
).toMatchObject({
expect(toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])).toMatchObject({
type: 'readModelInvalidated',
invalidation: {
context: true,
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
consumeRealtimeAccessGrantHeader,
createRealtimeAccessGrant,
REALTIME_ACCESS_GRANT_TTL_MS,
registerRealtimeAccessGrant,
verifyRealtimeAccessGrant,
verifyRealtimeAccessGrantHeader,
} from '../src/auth/realtimeAccessGrant.js';
const secret = 'realtime-access-grant-test-secret-with-enough-entropy';
const now = new Date('2026-08-17T10:00:00.000Z');
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-17T09:00:00.000Z',
expiresAt: '2026-08-17T11:00:00.000Z',
sessionId: 'session-private-value',
user: {
id: 'user-private-value',
username: 'grant-user',
displayName: '갱신 사용자',
roles: ['user'],
},
sanctions: {},
};
describe('realtime access grant', () => {
it('binds an opaque short-lived grant to the authenticated session and profile', () => {
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
expect(grant).not.toContain(auth.user.id);
expect(grant).not.toContain(auth.sessionId);
expect(grant).not.toContain('hwe:default');
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, now)).toBe(true);
expect(verifyRealtimeAccessGrantHeader([grant], auth, 'hwe:default', secret, now)).toBe(true);
expect(
verifyRealtimeAccessGrant(grant, { ...auth, sessionId: 'another-session' }, 'hwe:default', secret, now)
).toBe(false);
expect(verifyRealtimeAccessGrant(grant, auth, 'che:default', secret, now)).toBe(false);
});
it('rejects expired, tampered, unauthenticated, and malformed grants', () => {
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
const atExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS);
const afterExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS + 1);
const grantParts = grant.split('.');
const encryptedPart = grantParts[1] ?? '';
grantParts[1] = `${encryptedPart.startsWith('A') ? 'B' : 'A'}${encryptedPart.slice(1)}`;
const tampered = grantParts.join('.');
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, atExpiry)).toBe(false);
expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, afterExpiry)).toBe(false);
expect(verifyRealtimeAccessGrant(tampered, auth, 'hwe:default', secret, now)).toBe(false);
expect(verifyRealtimeAccessGrant(grant, null, 'hwe:default', secret, now)).toBe(false);
expect(verifyRealtimeAccessGrant('not-a-grant', auth, 'hwe:default', secret, now)).toBe(false);
});
it('registers a grant in Redis and consumes it exactly once', async () => {
const values = new Set<string>();
const redis = {
set: async (key: string) => {
if (values.has(key)) return null;
values.add(key);
return 'OK';
},
eval: async (_script: string, options: { keys: string[] }) =>
values.delete(options.keys[0] ?? '') ? 1 : 0,
};
const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now);
await expect(registerRealtimeAccessGrant(redis, grant, 'hwe:default')).resolves.toBe(true);
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
true
);
await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe(
false
);
});
});