토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
240 lines
9.1 KiB
TypeScript
240 lines
9.1 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { initTRPC, TRPCError } from '@trpc/server';
|
|
import { ChangeJournal } from '@sammo-ts/common';
|
|
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
|
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
|
|
|
|
import type { GameApiContext } from './context.js';
|
|
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
|
|
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
|
|
import {
|
|
formatGeneralAccessLimitMessage,
|
|
generalAccessLimitEndpoints,
|
|
getGeneralAccessState,
|
|
recordGeneralAccessWeight,
|
|
resolveGeneralAccessEndpointWeight,
|
|
shouldRecordGeneralAccessEndpoint,
|
|
type GeneralAccessEndpoint,
|
|
} from './services/generalAccess.js';
|
|
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
|
import { recordGeneralActivity } from './services/generalActivity.js';
|
|
|
|
const t = initTRPC.context<GameApiContext>().create();
|
|
|
|
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
|
if (!ctx.auth) {
|
|
throw new TRPCError({
|
|
code: 'UNAUTHORIZED',
|
|
message: 'Unauthorized',
|
|
});
|
|
}
|
|
const profileNames = ctx.profile ? [ctx.profile.name, ctx.profile.id] : [];
|
|
if (isGameAccessBlocked(ctx.auth.sanctions, profileNames)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Game access is restricted for this account.',
|
|
});
|
|
}
|
|
return next({
|
|
ctx: {
|
|
...ctx,
|
|
auth: ctx.auth,
|
|
},
|
|
});
|
|
});
|
|
|
|
const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
|
const result = await next();
|
|
if (type !== 'mutation' || !result.ok || ctx.generalAccessTracking !== true) {
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
await recordGeneralActivity(ctx);
|
|
} catch {
|
|
// 활동 표시는 업무 transaction보다 약한 보조 기록이다. 배포 중 schema
|
|
// 전환이나 일시 DB 오류가 이미 완료된 사용자 mutation을 실패시키지 않는다.
|
|
}
|
|
return result;
|
|
});
|
|
|
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
|
return next();
|
|
}
|
|
|
|
const requestId = `${ctx.requestId ?? randomUUID()}:${path}`;
|
|
const changeJournal = new ChangeJournal();
|
|
let journalPersisted = false;
|
|
try {
|
|
const result = await executeInputEvent({
|
|
db: ctx.db,
|
|
requestId,
|
|
eventType: path,
|
|
actorUserId: ctx.auth?.user.id,
|
|
execute: async (transaction) => {
|
|
const result = await next({
|
|
ctx: {
|
|
...ctx,
|
|
db: transaction,
|
|
changeJournal,
|
|
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
|
},
|
|
});
|
|
if (!result.ok) {
|
|
throw result.error;
|
|
}
|
|
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
|
return result;
|
|
},
|
|
});
|
|
if (journalPersisted) {
|
|
ctx.readModelOutbox?.wake();
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof DuplicateInputEventError) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: error.message,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
|
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
|
// Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다.
|
|
if (ctx.generalAccessTracking !== true) {
|
|
return next();
|
|
}
|
|
const weight = resolveGeneralAccessEndpointWeight(path, input, ctx.profile.name);
|
|
if (weight === undefined) {
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: `General access endpoint is not registered: ${path}`,
|
|
});
|
|
}
|
|
if (weight === null) {
|
|
return next();
|
|
}
|
|
const endpoint = path as GeneralAccessEndpoint;
|
|
if (shouldRecordGeneralAccessEndpoint(endpoint, ctx.realtimeAccessGranted)) {
|
|
await recordGeneralAccessWeight(ctx, weight);
|
|
}
|
|
if (generalAccessLimitEndpoints.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();
|
|
});
|
|
|
|
const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
|
|
if (ctx.generalAccessTracking !== true) {
|
|
return next();
|
|
}
|
|
const state = await getDeferredGeneralAccessLimit(ctx.redis, ctx.profile.name, ctx.auth);
|
|
if (state) {
|
|
throw new TRPCError({
|
|
code: 'TOO_MANY_REQUESTS',
|
|
message: formatGeneralAccessLimitMessage(state),
|
|
});
|
|
}
|
|
return next();
|
|
});
|
|
|
|
export const router = t.router;
|
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
|
export const authedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalActivityMiddleware)
|
|
.use(inputEventMiddleware);
|
|
|
|
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
|
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
|
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
|
|
export const accessAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalAccessEndpointMiddleware)
|
|
.use(generalActivityMiddleware)
|
|
.use(inputEventMiddleware);
|
|
|
|
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
|
|
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
|
|
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
|
|
export const engineAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
export const engineProcedure: typeof procedure = t.procedure;
|
|
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalAccessEndpointMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
|
|
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
|
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
|
export const sessionActivityProcedure = t.procedure;
|
|
|
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(generalAccessLimitMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.use(deferredGeneralAccessLimitMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
|
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
|
export const accessInputProcedure: typeof procedure.input = (input) =>
|
|
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
|
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
|
t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.input(input)
|
|
.use(generalAccessEndpointMiddleware)
|
|
.use(generalActivityMiddleware)
|
|
.use(inputEventMiddleware);
|
|
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
|
t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.input(input)
|
|
.use(generalAccessEndpointMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
|
|
t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.input(input)
|
|
.use(generalAccessEndpointMiddleware)
|
|
.use(generalActivityMiddleware);
|
|
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
|
|
t.procedure
|
|
.use(requireAuthMiddleware)
|
|
.input(input)
|
|
.use(generalAccessLimitMiddleware)
|
|
.use(generalActivityMiddleware);
|