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:
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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() };
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -6,6 +6,7 @@ import fs from 'node:fs/promises';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
REALTIME_ACCESS_GRANT_HEADER,
|
||||
trpcJsonBodyHttpServerOptions,
|
||||
type RealtimeViewerIdentity,
|
||||
} from '@sammo-ts/common';
|
||||
@@ -22,6 +23,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';
|
||||
@@ -227,6 +233,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,
|
||||
@@ -304,8 +317,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,
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user