fix: 갱신 비용에 따라 접속 점수를 기록

revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
This commit is contained in:
2026-08-17 11:10:38 +00:00
parent a80d58f761
commit 19629fe3cc
18 changed files with 643 additions and 96 deletions
@@ -0,0 +1,143 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
export const REALTIME_ACCESS_GRANT_TTL_MS = 15_000;
type RealtimeAccessGrantPayload = {
version: 1;
profile: string;
sessionId: string;
userId: string;
expiresAt: number;
};
const GRANT_KEY_CONTEXT = 'sammo:realtime-access-grant:v1';
const MAX_GRANT_LENGTH = 1_024;
interface RedisClientLike {
set(key: string, value: string, options: { NX: true; PX: number }): Promise<string | null>;
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
const CONSUME_GRANT_SCRIPT = `
if redis.call('DEL', KEYS[1]) == 1 then
return 1
end
return 0
`;
const buildKey = (secret: string): Buffer =>
createHash('sha256').update(GRANT_KEY_CONTEXT).update('\0').update(secret).digest();
const buildUsageKey = (profileName: string, grant: string): string =>
`sammo:game:realtime-access-grant:${profileName}:${createHash('sha256').update(grant).digest('base64url')}`;
const parsePayload = (value: unknown): RealtimeAccessGrantPayload | null => {
if (!value || typeof value !== 'object') return null;
const payload = value as Partial<RealtimeAccessGrantPayload>;
if (
payload.version !== 1 ||
typeof payload.profile !== 'string' ||
typeof payload.sessionId !== 'string' ||
typeof payload.userId !== 'string' ||
typeof payload.expiresAt !== 'number' ||
!Number.isSafeInteger(payload.expiresAt)
) {
return null;
}
return payload as RealtimeAccessGrantPayload;
};
export const createRealtimeAccessGrant = (
auth: GameSessionTokenPayload,
profileName: string,
secret: string,
now = new Date()
): string => {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', buildKey(secret), iv);
const payload: RealtimeAccessGrantPayload = {
version: 1,
profile: profileName,
sessionId: auth.sessionId,
userId: auth.user.id,
expiresAt: now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS,
};
const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
return [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString('base64url')).join('.');
};
export const verifyRealtimeAccessGrant = (
grant: string | null | undefined,
auth: GameSessionTokenPayload | null,
profileName: string,
secret: string,
now = new Date()
): boolean => {
if (!grant || grant.length > MAX_GRANT_LENGTH || !auth) return false;
const parts = grant.split('.');
if (parts.length !== 3) return false;
try {
const [ivPart, encryptedPart, tagPart] = parts;
const decipher = createDecipheriv('aes-256-gcm', buildKey(secret), Buffer.from(ivPart, 'base64url'));
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(encryptedPart, 'base64url')),
decipher.final(),
]).toString('utf8');
const payload = parsePayload(JSON.parse(plaintext));
return Boolean(
payload &&
payload.expiresAt > now.getTime() &&
payload.profile === profileName &&
payload.sessionId === auth.sessionId &&
payload.userId === auth.user.id
);
} catch {
return false;
}
};
export const verifyRealtimeAccessGrantHeader = (
header: string | string[] | undefined,
auth: GameSessionTokenPayload | null,
profileName: string,
secret: string,
now = new Date()
): boolean => verifyRealtimeAccessGrant(Array.isArray(header) ? header[0] : header, auth, profileName, secret, now);
export const registerRealtimeAccessGrant = async (
redis: RedisClientLike,
grant: string,
profileName: string
): Promise<boolean> =>
(await redis.set(buildUsageKey(profileName, grant), '1', {
NX: true,
PX: REALTIME_ACCESS_GRANT_TTL_MS,
})) === 'OK';
export const consumeRealtimeAccessGrantHeader = async (
redis: RedisClientLike,
header: string | string[] | undefined,
auth: GameSessionTokenPayload | null,
profileName: string,
secret: string,
now = new Date()
): Promise<boolean> => {
const grant = Array.isArray(header) ? header[0] : header;
if (!verifyRealtimeAccessGrant(grant, auth, profileName, secret, now) || !grant || !redis.eval) {
return false;
}
try {
return (
Number(
await redis.eval(CONSUME_GRANT_SCRIPT, {
keys: [buildUsageKey(profileName, grant)],
arguments: [],
})
) === 1
);
} catch {
return false;
}
};
+4
View File
@@ -87,6 +87,8 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
requestId?: string;
generalAccessTracking?: boolean;
/** Validated server-issued proof for one realtime refresh burst. */
realtimeAccessGranted?: boolean;
/** Request-local identity already resolved by the realtime access gate. */
realtimeAccessGeneralId?: number;
/** Set only while an API input-event transaction owns the mutation. */
@@ -115,6 +117,7 @@ export interface GameApiContext {
export const createGameApiContext = (options: {
requestId?: string;
realtimeAccessGranted?: boolean;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
@@ -136,6 +139,7 @@ export const createGameApiContext = (options: {
return {
requestId: options.requestId,
generalAccessTracking: true,
...(options.realtimeAccessGranted ? { realtimeAccessGranted: true } : {}),
db: options.db,
redis: options.redis,
turnDaemon: options.turnDaemon,
+7 -7
View File
@@ -31,10 +31,7 @@ const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null =>
return null;
};
export const shouldReloadRealtimeViewerIdentity = (
event: RealtimeEvent,
identity: RealtimeViewerIdentity
): boolean => {
export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identity: RealtimeViewerIdentity): boolean => {
if (identity.generalId === null) return false;
const changes = eventChanges(event);
if (!changes) return false;
@@ -57,7 +54,8 @@ export const shouldReloadRealtimeViewerIdentity = (
*/
export const toPublicRealtimeEvent = (
event: RealtimeEvent,
identities: readonly RealtimeViewerIdentity[]
identities: readonly RealtimeViewerIdentity[],
createRefreshGrant: () => string
): PublicRealtimeEvent | null => {
const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
@@ -65,13 +63,14 @@ export const toPublicRealtimeEvent = (
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
? { type: 'messagesInvalidated' }
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null;
}
if (event.type === 'tournamentChanged') {
return {
type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: {
context: false,
lobby: false,
@@ -90,6 +89,7 @@ export const toPublicRealtimeEvent = (
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: createFullRealtimeReadModelInvalidation(),
};
}
@@ -100,5 +100,5 @@ export const toPublicRealtimeEvent = (
.map((identity) => resolveRealtimeReadModelInvalidation(changes, identity))
.reduce(mergeRealtimeReadModelInvalidations);
if (!hasRealtimeReadModelInvalidation(invalidation)) return null;
return { type: 'readModelInvalidated', invalidation };
return { type: 'readModelInvalidated', invalidation, refreshGrant: createRefreshGrant() };
};
+74 -14
View File
@@ -10,6 +10,12 @@ import {
type DashboardSourceSlice,
} from '../../services/dashboardSourceRevision.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import {
DASHBOARD_PROJECTION_ACCESS_WEIGHT,
formatGeneralAccessLimitMessage,
getGeneralAccessState,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js';
import { getTurnCommandTable } from '../turns/index.js';
@@ -39,6 +45,31 @@ const zContextBundleInput = z.object({
forceSnapshot: z.boolean().optional(),
});
type DashboardSliceRequest = {
included: boolean;
sourceState: DashboardSourceRevisionState | null;
slice: DashboardSourceSlice;
knownContent?: string;
knownSource?: string;
forceSnapshot?: boolean;
};
export const requiresDashboardProjection = (request: DashboardSliceRequest): boolean => {
if (!request.included) return false;
const sourceRevision = request.sourceState?.sourceRevisions[request.slice];
return !(
sourceRevision !== undefined &&
request.knownContent !== undefined &&
canUseDashboardSourceRevision({
state: request.sourceState,
slice: request.slice,
knownContent: request.knownContent,
knownSource: request.knownSource,
forceSnapshot: request.forceSnapshot,
})
);
};
const createDashboardSliceDelta = async <T>(options: {
included: boolean;
sourceState: DashboardSourceRevisionState | null;
@@ -54,17 +85,7 @@ const createDashboardSliceDelta = async <T>(options: {
}
const sourceRevision = options.sourceState?.sourceRevisions[options.slice];
if (
sourceRevision !== undefined &&
options.knownContent !== undefined &&
canUseDashboardSourceRevision({
state: options.sourceState,
slice: options.slice,
knownContent: options.knownContent,
knownSource: options.knownSource,
forceSnapshot: options.forceSnapshot,
})
) {
if (!requiresDashboardProjection(options) && options.knownContent !== undefined) {
return {
kind: 'unchanged',
revision: options.knownContent,
@@ -102,9 +123,48 @@ export const dashboardRouter = router({
)?.id ??
null;
}
const sourceState = generalId
? await readDashboardSourceRevisionState(ctx.db, generalId, authUser)
: null;
let sourceState = generalId ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null;
const buildSliceRequests = (): DashboardSliceRequest[] => [
{
included: input.include.context,
sourceState,
slice: 'context',
knownContent: input.known?.context,
knownSource: input.knownSource?.context,
forceSnapshot: input.forceSnapshot,
},
{
included: input.include.commandTable && generalId !== null,
sourceState,
slice: 'commandTable',
knownContent: input.known?.commandTable,
knownSource: input.knownSource?.commandTable,
forceSnapshot: input.forceSnapshot,
},
{
included: input.include.boardAccess && generalId !== null,
sourceState,
slice: 'boardAccess',
knownContent: input.known?.boardAccess,
knownSource: input.knownSource?.boardAccess,
forceSnapshot: input.forceSnapshot,
},
];
const sliceRequests = buildSliceRequests();
const rebuildsPostgresProjection = sliceRequests.some(requiresDashboardProjection);
if (rebuildsPostgresProjection && ctx.generalAccessTracking === true && ctx.realtimeAccessGranted !== true) {
const recorded = await recordGeneralAccessWeight(ctx, DASHBOARD_PROJECTION_ACCESS_WEIGHT);
const accessState = await getGeneralAccessState(ctx);
if (accessState?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(accessState),
});
}
if (recorded && generalId !== null) {
sourceState = await readDashboardSourceRevisionState(ctx.db, generalId, authUser);
}
}
const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
createDashboardSliceDelta({
+26 -2
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common';
import { buildGameEventChannel, REALTIME_ACCESS_GRANT_HEADER, type RealtimeViewerIdentity } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
@@ -18,6 +18,11 @@ import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import {
consumeRealtimeAccessGrantHeader,
createRealtimeAccessGrant,
registerRealtimeAccessGrant,
} from './auth/realtimeAccessGrant.js';
import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
@@ -222,6 +227,13 @@ export const createGameApiServer = async () => {
uploadPublicUrl: config.uploadPublicUrl,
contentImageUpload,
auth,
realtimeAccessGranted: await consumeRealtimeAccessGrantHeader(
redis.client,
req.headers[REALTIME_ACCESS_GRANT_HEADER],
auth,
config.profileName,
config.gameTokenSecret
),
...(auth && token ? { accessToken: token } : {}),
accessTokenStore,
flushStore,
@@ -299,8 +311,20 @@ export const createGameApiServer = async () => {
identities.push(nextIdentity);
viewerIdentity = nextIdentity;
}
const publicEvent = toPublicRealtimeEvent(event, identities);
let refreshGrant: string | undefined;
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
return refreshGrant;
});
if (!publicEvent || closed) return;
if (refreshGrant) {
try {
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
} catch {
// Preserve the invalidation. An unregistered grant safely falls back
// to the normal scored refresh path.
}
}
sendFrame(
formatSseFrame({
event: publicEvent.type,
+4 -3
View File
@@ -25,6 +25,9 @@ export const accessPageWeights: Record<AccessPage, number> = {
'npc-control': 1,
};
/** One user-visible refresh that has to rebuild PostgreSQL-backed dashboard data. */
export const DASHBOARD_PROJECTION_ACCESS_WEIGHT = 1;
export const generalAccessEndpointWeights = {
'world.getGeneralDirectory': 2,
'public.getNpcList': 2,
@@ -369,9 +372,7 @@ export const upsertGeneralAccess = async (
`
);
await writeReadModelChangeJournal(transaction, [
{ domain: 'access.general', entityId: input.generalId },
]);
await writeReadModelChangeJournal(transaction, [{ domain: 'access.general', entityId: input.generalId }]);
});
};