운영 보안을 보존하여 빌드 개선 배포본 통합

This commit is contained in:
2026-10-23 01:33:42 +09:00
97 changed files with 5522 additions and 740 deletions
+65 -1
View File
@@ -1,3 +1,10 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
parseSessionRevocationWatermark,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
}
export interface FlushStore {
getFlushedAt(userId: string): Date | null;
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
applyFlush(event: GatewayUserFlushEvent): void;
}
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
}
}
interface RevocationRedisClient {
readonly isReady?: boolean;
mGet(keys: string[]): Promise<(string | null)[]>;
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
}
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
export class RedisFlushStore implements FlushStore {
private readonly local = new Map<string, Date>();
private readonly commands = new SessionRevocationCommandBudget(64);
constructor(
private readonly client: RevocationRedisClient,
private readonly channel: string
) {}
private commandClient(): RevocationRedisClient {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async assertInitialized(): Promise<void> {
const values = await this.commands.run(() =>
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
);
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
throw new Error('Gateway session revocation must be initialized before game API startup.');
}
async getFlushedAt(userId: string): Promise<Date | null> {
const values = await this.commands.run(() =>
this.commandClient().mGet([
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
])
);
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
const baseline = parseSessionRevocationWatermark(values[0]!);
if (!baseline) throw new Error('Session revocation storage is unavailable.');
const persisted = parseSessionRevocationWatermark(values[1]!);
return new Date(
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
);
}
applyFlush(event: GatewayUserFlushEvent): void {
const parsed = new Date(event.flushedAt);
if (Number.isNaN(parsed.getTime())) return;
const prior = this.local.get(event.userId);
if (!prior || parsed > prior) {
this.local.delete(event.userId);
this.local.set(event.userId, parsed);
}
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
}
}
export class RedisGatewayFlushSubscriber {
private readonly client: {
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
+6 -1
View File
@@ -1,4 +1,4 @@
import { parseNumberWithFallback } from '@sammo-ts/common';
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
const parseReconcileInterval = (value: string | undefined): number => {
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
export interface GameApiConfig {
host: string;
corsAllowedOrigins: string[];
port: number;
trpcPath: string;
eventsPath: string;
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
return {
host: env.GAME_API_HOST ?? '0.0.0.0',
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
env.GATEWAY_PUBLIC_URL,
env.GAME_PUBLIC_URL,
]),
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
@@ -0,0 +1,90 @@
import type { ServerResponse } from 'node:http';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
const MAX_PENDING_FRAMES = 32;
const MAX_BUFFER_BYTES = 64 * 1024;
/** Revalidate before work and before writing its result; never retain unbounded frames. */
export class AuthenticatedRealtimeConnection {
private queue = Promise.resolve();
private pending = 0;
private ended = false;
private readonly expiryTimer: ReturnType<typeof setTimeout>;
private cleanup = new Set<() => void>();
constructor(
readonly auth: GameSessionTokenPayload,
private readonly response: ServerResponse,
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
) {
// AccessTokenStore requires a full remaining TTL second on every lookup.
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
this.expiryTimer.unref();
response.once('close', this.close);
response.once('error', this.close);
}
get closed(): boolean {
return this.ended;
}
whenIdle(): Promise<void> {
return this.queue;
}
onClose(cleanup: () => void): void {
if (this.ended) cleanup();
else this.cleanup.add(cleanup);
}
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
if (this.ended) return null;
const current = await this.resolveAuth();
if (
!current ||
this.ended ||
current.sessionId !== this.auth.sessionId ||
current.user.id !== this.auth.user.id ||
current.profile !== this.auth.profile
) {
this.close();
return null;
}
return current;
}
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
if (this.ended) return;
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
this.close();
return;
}
this.pending += 1;
this.queue = this.queue
.then(async () => {
const current = await this.currentAuth();
if (!current) return;
const frame = await work(current);
if (!frame || !(await this.currentAuth())) return;
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
})
.catch(() => {
// Redis/auth failure is fail closed. Reconnect must authenticate again.
this.close();
})
.finally(() => {
this.pending -= 1;
});
}
close = (): void => {
if (this.ended) return;
this.ended = true;
clearTimeout(this.expiryTimer);
this.response.off('close', this.close);
this.response.off('error', this.close);
for (const cleanup of this.cleanup) cleanup();
this.cleanup.clear();
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
};
}
@@ -0,0 +1,93 @@
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
const OPENING_DEADLINE_MS = 5000;
export interface RealtimeConnectionReservation {
canContinue(): boolean;
reserveUser(userId: string): boolean;
startStreaming(): boolean;
cancelOpening(): void;
release(): void;
}
/** One game API process owns both pending setup and live streams; no waiting queue. */
export class RealtimeConnectionAdmission {
private readonly limits: { connections: number; perUser: number; opening: number };
private total = 0;
private opening = 0;
private closed = false;
private readonly byUser = new Map<string, number>();
private readonly pending = new Set<() => void>();
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
throw new Error('Invalid realtime connection limits.');
}
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
this.total += 1;
this.opening += 1;
let userId: string | null = null;
let streaming = false;
let cancelled = false;
let released = false;
const deadline = Date.now() + OPENING_DEADLINE_MS;
const expire = () => {
if (released || streaming || cancelled) return;
cancelled = true;
clearTimeout(timer);
onTimeout();
};
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
timer.unref();
this.pending.add(expire);
const canContinue = () => {
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
return !released && !streaming && !cancelled;
};
return {
canContinue,
reserveUser: (id) => {
if (!canContinue() || userId !== null) return false;
const count = this.byUser.get(id) ?? 0;
if (count >= this.limits.perUser) return false;
this.byUser.set(id, count + 1);
userId = id;
return true;
},
startStreaming: () => {
if (!canContinue() || userId === null) return false;
streaming = true;
this.opening -= 1;
clearTimeout(timer);
this.pending.delete(expire);
return true;
},
cancelOpening: () => {
if (released || streaming) return;
cancelled = true;
clearTimeout(timer);
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
},
release: () => {
if (released) return;
released = true;
this.total -= 1;
if (!streaming) this.opening -= 1;
clearTimeout(timer);
this.pending.delete(expire);
if (userId !== null) {
const count = (this.byUser.get(userId) ?? 1) - 1;
if (count === 0) this.byUser.delete(userId);
else this.byUser.set(userId, count);
}
},
};
}
close(): void {
this.closed = true;
for (const expire of this.pending) expire();
}
}
+9 -1
View File
@@ -66,7 +66,15 @@ export const authRouter = router({
message: 'Game access is restricted for this account.',
});
}
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
let flushedAt: Date | null;
try {
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
} catch {
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Authentication protection is unavailable.',
});
}
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
throw new TRPCError({
code: 'UNAUTHORIZED',
+128 -63
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { randomBytes } from 'crypto';
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
const boardImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 1024 * 1024;
const MAX_LONG_EDGE = 2048;
const WEBP_QUALITY = 80;
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
buffer: Buffer,
{ animated, resize }: { animated: boolean; resize: boolean }
): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true });
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) {
pipeline = pipeline.resize({
width: MAX_LONG_EDGE,
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
...(animated ? { animated: true } : {}),
};
return pipeline.webp(webpOptions).toBuffer();
return pipeline
.webp(webpOptions)
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
};
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true });
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) {
pipeline = pipeline.resize({
width: MAX_LONG_EDGE,
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
withoutEnlargement: true,
});
}
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
return pipeline
.avif({ quality: 60, effort: 4 })
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
};
export const boardRouter = router({
@@ -213,70 +232,116 @@ export const boardRouter = router({
return { id: comment.id };
}),
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, true);
uploadImage: authedProcedure
.input(
z.object({
dataUrl: z
.string()
.min(1)
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
})
)
.mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, true);
const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
}
try {
return await boardImageBudget.run(async () => {
const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({
code: 'PAYLOAD_TOO_LARGE',
message: '이미지 용량 제한(1MB)을 초과했습니다.',
});
}
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
if (!isRasterImage(buffer))
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
});
if ((metadata.pages ?? 1) > 256)
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
const format = metadata.format;
const isAvif = metadata.mediaType === 'image/avif';
const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
const format = metadata.format;
const isAvif = metadata.mediaType === 'image/avif';
const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
if (!allowed.has(format)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
}
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
if (!allowed.has(format)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
}
let outputBuffer = buffer;
let outputFormat = isAvif ? 'avif' : 'webp';
let outputBuffer = buffer;
let outputFormat = isAvif ? 'avif' : 'webp';
if (isAvif) {
if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true);
if (isAvif) {
if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true);
}
} else {
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (
format === 'webp' &&
!needsResize &&
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
}
if (!ctx.contentImageUpload) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
}
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
const uploaded = await ctx.contentImageUpload.upload({
filename,
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
body: outputBuffer,
});
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata();
return {
url: uploaded.publicUrl,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
});
} catch (error) {
if (error instanceof ImageWorkBusyError)
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
});
if (error instanceof ImageUploadError)
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
});
throw error;
}
} else {
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
}
if (!ctx.contentImageUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
const uploaded = await ctx.contentImageUpload.upload({
filename,
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
body: outputBuffer,
});
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
return {
url: uploaded.publicUrl,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
}),
}),
});
+55 -70
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js';
@@ -18,6 +18,16 @@ import {
} from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
if (result?.type === 'commandRejected') {
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
}
if (!result || result.type !== 'tournamentAdjustGeneral') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
}
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
};
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true;
@@ -197,6 +207,7 @@ const zMatch = z.object({
const zBetEntry = z.object({
generalId: z.number().int().positive(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int().positive(),
amount: z.number().int().positive(),
});
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
});
}),
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, entries, matches] = await Promise.all([
store.getState(),
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
}
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
totalAmount += entry.amount;
if (entry.generalId === general.id) {
if (entry.userId === ctx.auth!.user.id) {
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
myAmount += entry.amount;
}
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: -develCost,
minGoldAfter: 0,
});
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!feeResult.ok || feeResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
});
}
assertTournamentAdjustment(feeResult);
const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
try {
await store.setParticipants(next);
} catch (error) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: develCost,
minGoldAfter: 0,
})
);
throw error;
}
return { ok: true, count: next.length };
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
}
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
if (entries.some((entry) => entry.userId === undefined)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
}
const previousBetAmount = entries
.filter((entry) => entry.generalId === general.id)
.filter((entry) => entry.userId === ctx.auth!.user.id)
.reduce((sum, entry) => sum + entry.amount, 0);
if (previousBetAmount + input.amount > 1_000) {
throw new TRPCError({
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
}
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: -input.amount,
betGoldDelta: input.amount,
minGoldAfter: 500,
});
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!adjustResult.ok || adjustResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
});
}
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
generalId: general.id,
metaDelta: { betgold: input.amount },
},
],
});
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
}
assertTournamentAdjustment(adjustResult);
try {
await store.appendBettingEntry({
generalId: general.id,
userId: ctx.auth!.user.id,
targetId: input.targetId,
amount: input.amount,
});
} catch (error) {
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
generalId: general.id,
metaDelta: { betgold: -input.amount },
},
],
}),
]);
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: input.amount,
betGoldDelta: -input.amount,
minGoldAfter: 0,
})
);
throw error;
}
return { ok: true };
+184 -108
View File
@@ -1,8 +1,10 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
buildGameEventChannel,
@@ -21,7 +23,7 @@ import {
import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import {
consumeRealtimeAccessGrantHeader,
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
import { formatSseFrame } from './realtime/sse.js';
import {
shouldForwardRealtimeEvent,
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
const resolveAuthFromToken = async (
token: string | null,
accessTokenStore: RedisAccessTokenStore,
flushStore: FlushStore
flushStore: FlushStore,
profileName: string
): Promise<GameSessionTokenPayload | null> => {
if (!token) {
return null;
}
const stored = await accessTokenStore.get(token);
if (!stored) {
if (!stored || stored.profile !== profileName) {
return null;
}
const flushedAt = flushStore.getFlushedAt(stored.user.id);
let flushedAt: Date | null;
try {
flushedAt = await flushStore.getFlushedAt(stored.user.id);
} catch {
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
}
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
return null;
}
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
imageUploadSecret
);
const app = fastify({
logger: true,
logger: safeHttpLoggerOptions,
routerOptions: {
maxParamLength: 2048,
},
});
app.setErrorHandler(safeHttpErrorHandler);
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
const realtimeAdmission = new RealtimeConnectionAdmission();
app.addHook('preClose', async () => {
realtimeAdmission.close();
for (const connection of realtimeConnections) connection.close();
});
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
const redis = createRedisConnector(resolveRedisConfigFromEnv());
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
requestTimeoutMs: config.battleSimRequestTimeoutMs,
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const flushStore = new InMemoryFlushStore();
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
try {
await flushStore.assertInitialized();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
}
const flushSubscriberClient = redis.client.duplicate();
try {
await flushSubscriberClient.connect();
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
config.flushChannel,
flushStore,
async (event) => {
const flushedAt = Date.parse(event.flushedAt);
for (const connection of realtimeConnections) {
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
connection.close();
}
}
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
},
(error, event) => {
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
app.addHook('onClose', closeResources);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, {
origin: true,
origin: config.corsAllowedOrigins,
credentials: true,
});
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => {
const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0]
: req.headers['idempotency-key'];
@@ -299,80 +335,124 @@ export const createGameApiServer = async () => {
},
});
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
if (
!auth ||
auth.profile !== config.profileName ||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
)
return null;
return auth;
};
app.get(config.eventsPath, async (request, reply) => {
const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
if (!auth) {
if (query.token !== undefined || !tokenFromHeader) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
});
if (!general) {
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
let expired = false;
let disconnected = false;
const opening = realtimeAdmission.begin(() => {
expired = true;
if (!reply.sent && !reply.raw.destroyed) {
reply.header('Retry-After', '15');
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
}
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
};
let viewerIdentity = await loadViewerIdentity();
reply.hijack();
const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
reply.raw.setHeader('Vary', 'Origin');
});
if (!opening) {
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
return;
}
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
request.raw.setTimeout(0);
reply.raw.setTimeout?.(0);
reply.raw.flushHeaders?.();
const sendFrame = (payload: string) => {
const cancelOpening = () => {
disconnected = true;
opening.cancelOpening();
};
request.raw.once('aborted', cancelOpening);
reply.raw.once('close', cancelOpening);
let transferred = false;
try {
let auth: GameSessionTokenPayload | null = null;
try {
reply.raw.write(payload);
auth = await resolveRealtimeAuth(tokenFromHeader);
} catch {
if (opening.canContinue() && !reply.raw.destroyed)
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
return;
}
};
if (!opening.canContinue() || reply.raw.destroyed) return;
sendFrame(
formatSseFrame({
event: 'ready',
data: '{}',
})
);
if (!auth || auth.profile !== config.profileName) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
if (!opening.reserveUser(auth.user.id)) {
if (opening.canContinue())
await reply
.header('Retry-After', '15')
.status(429)
.send({ ok: false, error: 'realtime_connection_limit' });
return;
}
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue
.then(async () => {
if (closed) return;
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
});
if (!general) {
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
}
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
};
let viewerIdentity = await loadViewerIdentity();
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
reply.hijack();
const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
reply.raw.setHeader('Vary', 'Origin');
}
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
request.raw.setTimeout(0);
reply.raw.setTimeout?.(0);
reply.raw.flushHeaders?.();
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
resolveRealtimeAuth(tokenFromHeader)
);
realtimeConnections.add(connection);
connection.onClose(() => {
realtimeConnections.delete(connection);
// A disconnected socket can still own an in-flight Redis/SQL operation.
void connection.whenIdle().then(opening.release, opening.release);
});
transferred = true;
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
connection.enqueue(async (currentAuth) => {
const identities = [viewerIdentity];
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity();
@@ -381,58 +461,54 @@ export const createGameApiServer = async () => {
}
let refreshGrant: string | undefined;
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
refreshGrant ??= createRealtimeAccessGrant(
currentAuth,
config.profileName,
config.gameTokenSecret
);
return refreshGrant;
});
if (!publicEvent || closed) return;
if (!publicEvent || connection.closed) return null;
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.
// An unregistered grant falls back to the scored refresh path.
}
}
sendFrame(
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
})
.catch(() => {
// A best-effort notification must not affect committed game state.
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
});
});
let heartbeatPending = false;
const heartbeat = setInterval(() => {
if (heartbeatPending) return;
heartbeatPending = true;
void turnEngineStatus
.get()
.then((turnEngineRunning) => {
if (closed) return;
sendFrame(
formatSseFrame({
});
connection.onClose(unsubscribe);
let heartbeatPending = false;
const heartbeat = setInterval(() => {
if (heartbeatPending || connection.closed) return;
heartbeatPending = true;
connection.enqueue(async () => {
try {
return formatSseFrame({
event: 'ping',
data: JSON.stringify({ turnEngineRunning }),
})
);
})
.finally(() => {
heartbeatPending = false;
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
});
} finally {
heartbeatPending = false;
}
});
}, 15000);
const close = () => {
closed = true;
clearInterval(heartbeat);
unsubscribe();
};
request.raw.on('close', close);
request.raw.on('aborted', close);
}, 15_000);
connection.onClose(() => {
clearInterval(heartbeat);
});
request.raw.once('aborted', connection.close);
connection.onClose(() => {
request.raw.off('aborted', connection.close);
});
} catch (error) {
if (!expired && !disconnected) throw error;
} finally {
request.raw.off('aborted', cancelOpening);
reply.raw.off('close', cancelOpening);
if (!transferred) opening.release();
}
});
app.get('/healthz', async (_request, reply) => {
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface ContentImageUploadResult {
publicUrl: string;
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid content image filename.');
}
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
const digest = createHash('sha256').update(input.body).digest('hex');
const signature = createHmac('sha256', this.secret)
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
.digest('hex');
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
await performSignedImageUpload(
{
...input,
kind: 'content',
baseUrl: this.baseUrl,
secret: this.secret,
},
body: new Uint8Array(input.body),
});
if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
const expectedPath = `uploads/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
throw new Error('Image repository returned an unexpected content path.');
}
this.fetchImpl,
this.now
);
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
}
}
+5 -1
View File
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
const zTournamentBet = z
.object({
generalId: z.number().int(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int(),
amount: z.number(),
})
@@ -286,7 +287,10 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries();
const existing = entries.find(
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
(candidate) =>
candidate.generalId === entry.generalId &&
candidate.targetId === entry.targetId &&
candidate.userId === entry.userId
);
if (existing) {
existing.amount += entry.amount;
+2
View File
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
export interface TournamentBetEntry {
generalId: number;
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
userId?: string | null;
targetId: number;
amount: number;
}
+18 -13
View File
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds);
if (!existingBettors.has(npc.id as number)) {
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
}
}
return entries;
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
}
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({
type: 'adjustGeneralResources',
requestId: `${requestPrefix}:resources`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
});
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
const result = await daemonTransport.requestCommand({
type: 'tournamentSeedNpcBets',
requestId: `${requestPrefix}:atomic`,
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
});
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
throw new Error('NPC tournament betting did not commit.');
}
const processed = new Set(result.processedGeneralIds);
const existing = await store.getBettingEntries();
const existingBettors = new Set(existing.map((entry) => entry.generalId));
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
await store.setBettingEntries(
existing.concat(
plan
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
.map((entry) => ({ ...entry, userId: null }))
)
);
};
+3 -2
View File
@@ -1,3 +1,4 @@
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
@@ -20,10 +21,10 @@ import {
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create();
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) {
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
@@ -0,0 +1,95 @@
import { PassThrough } from 'node:stream';
import type { ServerResponse } from 'node:http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
const payload = (): GameSessionTokenPayload => ({
version: 1,
sessionId: 'test-session',
profile: 'che:default',
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
user: {
id: 'test-user',
username: 'test',
displayName: 'test',
roles: ['user'],
createdAt: new Date().toISOString(),
},
sanctions: {},
});
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
const response = new PassThrough();
const frames: string[] = [];
response.on('data', (chunk) => frames.push(String(chunk)));
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
return { response, frames, connection };
};
afterEach(() => vi.useRealTimers());
describe('authenticated realtime connection', () => {
it('does not forward an in-flight result after the token is revoked during its work', async () => {
const auth = payload();
let current: GameSessionTokenPayload | null = auth;
const { connection, frames } = fixture(async () => current, auth);
connection.enqueue(async () => {
current = null;
return 'private invalidation';
});
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
const auth = payload();
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
connection.enqueue(async () => 'private invalidation');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it('fails closed when authentication storage fails', async () => {
const { connection, frames } = fixture(async () => {
throw new Error('redis unavailable');
});
connection.enqueue(async () => 'private invalidation');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it('ends an idle expired stream and releases subscriptions only once', async () => {
vi.useFakeTimers();
const auth = payload();
const { connection } = fixture(async () => auth, auth);
const cleanup = vi.fn();
connection.onClose(cleanup);
await vi.advanceTimersByTimeAsync(60_001);
expect(connection.closed).toBe(true);
expect(cleanup).toHaveBeenCalledTimes(1);
connection.close();
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('bounds queued work while an earlier event is blocked', async () => {
const auth = payload();
const { connection, frames } = fixture(async () => auth, auth);
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
connection.enqueue(async () => {
await waiting;
return 'private invalidation';
});
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
expect(connection.closed).toBe(true);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(frames).toEqual([]);
});
it('ends a slow socket instead of retaining further frames', async () => {
const auth = payload();
const { connection, response } = fixture(async () => auth, auth);
response.write = vi.fn(() => false);
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(response.writableEnded).toBe(true);
});
});
+75
View File
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
});
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
expect(png.length).toBeLessThan(1024 * 1024);
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
const pending: Array<() => void> = [];
const upload = vi.fn(
() =>
new Promise<{ publicUrl: string }>((resolve) => {
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
})
);
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const caller = appRouter.createCaller(fixture.context);
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
try {
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
});
it('rejects SVG before a native image parser or upload is used', async () => {
const upload = vi.fn();
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const caller = appRouter.createCaller(fixture.context);
const makeGif = (pages: number) =>
sharp(Buffer.alloc(pages * 4, 255), {
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
})
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
.toBuffer();
const excessive = await makeGif(257);
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(upload).not.toHaveBeenCalled();
const ordinary = await makeGif(3);
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
animated: true,
format: 'webp',
});
});
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) return;
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
if (keys.length > 0) await redis.client.del(keys);
}
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
if (!created) throw new Error('failed to seed diplomacy HTML access token');
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000);
+84 -1
View File
@@ -1,6 +1,8 @@
import { createServer, type Socket } from 'node:net';
import { createClient } from 'redis';
import { describe, expect, it, vi } from 'vitest';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
describe('RedisGatewayFlushSubscriber', () => {
it('reports asynchronous flush handler failures with event context', async () => {
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
expect(stopped).toBe(true);
});
});
describe('RedisFlushStore authorization state', () => {
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
let persisted: string | null = null;
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
await store.assertInitialized();
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
persisted = '200';
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
expect(mGet).toHaveBeenCalledTimes(4);
});
it('retains revocation after local cache eviction and a fresh store instance', async () => {
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
const store = new RedisFlushStore(client, 'fixture:flush');
for (let i = 0; i < 4100; i++)
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
});
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
'rejects missing or corrupt initialization %s',
async (baseline) => {
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
await expect(store.assertInitialized()).rejects.toThrow();
}
);
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
});
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
const offlineGet = vi.fn(async () => ['0', null]);
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
expect(offlineGet).not.toHaveBeenCalled();
const mGet = vi.fn(async () => ['0', '100']);
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
});
});
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
const sockets = new Set<Socket>();
let receivedRead = false;
const server = createServer((socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('data', (data) => {
const request = data.toString();
// Real node-redis handshake succeeds; its MGET command receives no reply.
if (request.includes('MGET')) {
receivedRead = true;
return;
}
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
socket.write('+OK\r\n'.repeat(count));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
try {
await client.connect();
expect(client.isReady).toBe(true);
const start = Date.now();
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
expect(receivedRead).toBe(true);
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
expect(Date.now() - start).toBeLessThan(4500);
} finally {
if (client.isOpen) client.destroy();
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
db,
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource,
} as unknown as GameApiContext;
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
auth: token,
db: { $executeRaw: executeRaw },
generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
}) as unknown as GameApiContext;
const activityRouter = router({
@@ -0,0 +1,51 @@
import fastify from 'fastify';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { describe, expect, it } from 'vitest';
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
const internalFailure = () => {
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
};
const badInput = () => {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '잘못된 입력입니다.',
cause: new Error('synthetic-private-cause'),
});
};
describe('public HTTP error privacy', () => {
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/game',
trpcOptions: {
router: gameRouter({
internal: sessionActivityProcedure.query(internalFailure),
invalid: sessionActivityProcedure.query(badInput),
}),
createContext: () => ({}),
},
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
for (const prefix of ['game']) {
for (const procedure of ['internal', 'invalid']) {
const response = await fetch(`${address}/${prefix}/${procedure}`);
const body = (await response.json()) as {
error: { message: string; data: { code: string; stack?: string } };
};
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
expect(body.error.message).toBe(
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
);
expect(body.error.data.stack).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('synthetic-');
}
}
} finally {
await app.close();
}
});
});
@@ -31,6 +31,7 @@ const buildContext = (
) =>
({
auth: options.auth === undefined ? auth : options.auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
findFirst: vi.fn(async () => ({
@@ -36,6 +36,7 @@ type LogQuery = {
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
({
auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({
MATCH: `sammo:game:*:${profileName}:*`,
COUNT: 100,
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
}
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000);
+2 -2
View File
@@ -20,7 +20,7 @@ const profile: GameProfile = {
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che',
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session',
@@ -239,7 +239,7 @@ const buildContext = (options?: {
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile: selectedProfile,
auth: options?.authenticated === false ? null : auth,
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PassThrough } from 'node:stream';
import type { ServerResponse } from 'node:http';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
afterEach(() => vi.useRealTimers());
describe('realtime connection admission', () => {
it('reserves the process total before authentication and counts live streams too', () => {
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
const first = budget.begin(vi.fn())!;
const second = budget.begin(vi.fn())!;
expect(first.reserveUser('actor')).toBe(true);
expect(first.startStreaming()).toBe(true);
expect(budget.begin(vi.fn())).toBeNull();
second.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement).not.toBeNull();
replacement.release();
first.release();
});
it('shares the authenticated actor limit while allowing another actor', () => {
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
const first = budget.begin(vi.fn())!;
expect(first.reserveUser('one')).toBe(true);
const rejected = budget.begin(vi.fn())!;
expect(rejected.reserveUser('one')).toBe(false);
expect(rejected.startStreaming()).toBe(false);
rejected.release();
const other = budget.begin(vi.fn())!;
expect(other.reserveUser('two')).toBe(true);
first.release();
first.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('one')).toBe(true);
other.release();
replacement.release();
});
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
vi.useFakeTimers();
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
const timeout = vi.fn();
const stalled = budget.begin(timeout)!;
const disconnected = budget.begin(vi.fn())!;
expect(stalled.reserveUser('stalled')).toBe(true);
disconnected.cancelOpening();
await vi.advanceTimersByTimeAsync(5000);
expect(timeout).toHaveBeenCalledTimes(1);
expect(stalled.canContinue()).toBe(false);
expect(stalled.startStreaming()).toBe(false);
expect(disconnected.canContinue()).toBe(false);
expect(budget.begin(vi.fn())).toBeNull();
stalled.release();
const recovered = budget.begin(vi.fn())!;
expect(recovered.reserveUser('stalled')).toBe(true);
expect(recovered.startStreaming()).toBe(true);
await vi.advanceTimersByTimeAsync(5000);
expect(timeout).toHaveBeenCalledTimes(1);
disconnected.release();
recovered.release();
});
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
const budget = new RealtimeConnectionAdmission();
const timeout = vi.fn();
const pending = budget.begin(timeout)!;
budget.close();
budget.close();
expect(timeout).toHaveBeenCalledTimes(1);
expect(pending.canContinue()).toBe(false);
expect(budget.begin(vi.fn())).toBeNull();
pending.release();
});
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
const slot = budget.begin(vi.fn())!;
expect(slot.reserveUser('actor')).toBe(true);
expect(slot.startStreaming()).toBe(true);
let finish!: () => void;
let started!: () => void;
const inFlight = new Promise<void>((resolve) => {
finish = resolve;
});
const running = new Promise<void>((resolve) => {
started = resolve;
});
const auth: GameSessionTokenPayload = {
version: 1,
sessionId: 'session',
profile: 'che:default',
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
user: {
id: 'actor',
username: 'actor',
displayName: 'actor',
roles: ['user'],
createdAt: new Date().toISOString(),
},
sanctions: {},
};
const response = new PassThrough();
const connection = new AuthenticatedRealtimeConnection(
auth,
response as unknown as ServerResponse,
async () => {
started();
await inFlight;
return auth;
}
);
connection.onClose(() => {
void connection.whenIdle().then(slot.release, slot.release);
});
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
await running;
connection.close();
expect(budget.begin(vi.fn())).toBeNull();
finish();
await connection.whenIdle();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('actor')).toBe(true);
replacement.release();
});
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
const budget = new RealtimeConnectionAdmission();
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
expect(pending.every(Boolean)).toBe(true);
expect(budget.begin(vi.fn())).toBeNull();
for (const slot of pending) slot.release();
const live = Array.from({ length: 512 }, (_, index) => {
const slot = budget.begin(vi.fn())!;
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
expect(slot.startStreaming()).toBe(true);
return slot;
});
expect(budget.begin(vi.fn())).toBeNull();
live[0]!.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('replacement')).toBe(true);
expect(replacement.startStreaming()).toBe(true);
for (const slot of live) slot.release();
replacement.release();
});
});
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
expect(Object.values(headers)).not.toContain(secret);
});
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteContentImageStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { Writable } from 'node:stream';
import fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
describe('HTTP log credential boundaries', () => {
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
let output = '';
const stream = new Writable({
write(chunk, _encoding, done) {
output += String(chunk);
done();
},
});
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
app.setErrorHandler(safeHttpErrorHandler);
app.post('/events', async () => {
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
code: 'P2002',
detail: 'synthetic-detail-secret',
});
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
method: 'POST',
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
body: JSON.stringify({ password: 'synthetic-body-secret' }),
});
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
await fetch(`${address}/synthetic-path-secret`);
const logs = output
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
expect(output).not.toContain(`synthetic-${field}-secret`);
}
} finally {
await app.close();
}
});
});
@@ -1,3 +1,5 @@
import sharp from 'sharp';
import { buildGameEventChannel } from '@sammo-ts/common';
import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
@@ -68,6 +70,7 @@ const envKeys = [
'GATEWAY_REDIS_PREFIX',
'GATEWAY_INTERNAL_API_URL',
'GAME_UPLOAD_DIR',
'API_ALLOWED_ORIGINS',
] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const keys of redis.client.scanIterator({
MATCH: pattern,
COUNT: 100,
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
if (!redis) return [];
const keys = new Set<string>();
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
for (const key of batch) keys.add(key);
}
@@ -640,6 +643,7 @@ integration('game API security over HTTP transport', () => {
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
process.env.GAME_UPLOAD_DIR = uploadDir;
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
await connector.connect();
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
await redis.connect();
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({
host: server.config.host,
@@ -818,6 +824,28 @@ integration('game API security over HTTP transport', () => {
}
});
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
const input = { dataUrl: 'data:image/png;base64,AA==' };
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
expect(unauthenticated.response.status).toBe(401);
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
expect(denied.response.status).toBe(403);
const officer = await createAccessToken('image-officer', {});
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
expect(malformed.response.status).toBe(400);
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const oversized = await requestTrpc('board.uploadImage', {
method: 'POST',
accessToken: officer,
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
});
expect(oversized.response.status).toBe(400);
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
});
it('accepts an authenticated query from a POST JSON body', async () => {
const accessToken = await createAccessToken('json-query-body', {});
const general = await requestTrpc('general.me', {
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
{
decisionId: decisionIds[0]!,
ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} }
: ({ ...step, sequence })),
steps: Array.from({ length: 128 }, (_, sequence) =>
sequence === 127
? {
...step,
sequence,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
secret: 'decision-secret',
},
secret: 'decision-secret',
},
}
: { ...step, sequence }
),
},
{
decisionId: decisionIds[0]!,
@@ -3530,6 +3600,414 @@ integration('game API security over HTTP transport', () => {
}
});
const openRealtime = async (accessToken: string) => {
const abort = new AbortController();
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
signal: abort.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body!.getReader();
const ready = await reader.read();
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
return { abort, reader };
};
it('SSE limits simultaneous openings for one authenticated user', async () => {
const actor = `security-http-sse-concurrent-${process.pid}`;
const token = await createAccessToken('sse-concurrent', {}, actor);
const aborts = Array.from({ length: 10 }, () => new AbortController());
try {
const responses = await Promise.all(
aborts.map((abort) =>
fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
signal: abort.signal,
})
)
);
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
for (const response of responses.filter((item) => item.status === 429)) {
expect(response.headers.get('retry-after')).toBe('15');
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
}
} finally {
for (const abort of aborts) abort.abort();
await accessTokenStore.revoke(token);
}
});
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
const actor = `security-http-sse-shared-${process.pid}`;
const token = await createAccessToken('sse-shared-a', {}, actor);
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
const replacementAbort = new AbortController();
try {
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
headers: { authorization: `Bearer ${otherToken}` },
});
expect(rejected.status).toBe(429);
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
streams.push(await openRealtime(otherActorToken));
await streams[0]!.reader.cancel();
streams[0]!.abort.abort();
let replacement: Response | undefined;
await expect
.poll(async () => {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
headers: { authorization: `Bearer ${otherToken}` },
signal: replacementAbort.signal,
});
if (response.status === 200) replacement = response;
else await response.arrayBuffer();
return response.status;
})
.toBe(200);
const ready = await replacement!.body!.getReader().read();
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
} finally {
replacementAbort.abort();
for (const stream of streams) stream.abort.abort();
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
}
});
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
const actor = `security-http-sse-pending-${process.pid}`;
const tokens = await Promise.all(
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
);
const aborts = Array.from({ length: 33 }, () => new AbortController());
let unlock!: () => void;
const release = new Promise<void>((resolve) => {
unlock = resolve;
});
let ready!: (tx: GamePrisma.TransactionClient) => void;
let lockError!: (error: unknown) => void;
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
ready = resolve;
lockError = reject;
});
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
const locking = db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
ready(tx);
await release;
},
{ timeout: 20_000 }
);
void locking.catch(lockError);
try {
const tx = await locked;
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[0]!}` },
signal: aborts[0]!.signal,
}).then(
() => false,
() => true
);
await expect
.poll(
async () => {
const rows = await tx.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(*)::int AS count FROM pg_locks
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
`;
return rows[0]!.count;
},
{ timeout: 3000 }
)
.toBeGreaterThan(0);
const startedAt = Date.now();
const waiting = tokens.slice(1, 32).map((token, index) =>
fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
signal: aborts[index + 1]!.signal,
})
);
aborts[0]!.abort();
expect(await first).toBe(true);
const responses = await Promise.all(waiting);
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
expect(Date.now() - startedAt).toBeLessThan(8500);
for (const response of responses) {
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
}
const refusedAt = Date.now();
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[32]!}` },
signal: aborts[32]!.signal,
});
expect(refused.status).toBe(503);
expect(refused.headers.get('retry-after')).toBe('15');
expect(Date.now() - refusedAt).toBeLessThan(2000);
await refused.arrayBuffer();
unlock();
await locking;
let recovered: Response | undefined;
await expect
.poll(async () => {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[32]!}` },
signal: aborts[32]!.signal,
});
if (response.status === 200) recovered = response;
else await response.arrayBuffer();
return response.status;
})
.toBe(200);
const frame = await recovered!.body!.getReader().read();
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
} finally {
unlock();
for (const abort of aborts) abort.abort();
await locking;
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
}
}, 15_000);
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
const token = await createAccessToken('origin-mutation', {});
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
origin: 'https://untrusted.example',
'content-type': 'application/json',
},
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(await accessTokenStore.get(token)).not.toBeNull();
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization,content-type',
},
});
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
expect(preflight.headers.get('access-control-allow-origin')).toBe(
origin === 'https://sam.hided.net' ? origin : null
);
}
await accessTokenStore.revoke(token);
});
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
const foreign = await accessTokenStore.create({
...buildPayload('trpc-foreign', {}),
profile: 'other:default',
});
if (!foreign) throw new Error('Foreign token fixture failed');
const before = await readReservedMutationState();
try {
const response = await fetch(
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
{
headers: { authorization: `Bearer ${foreign.accessToken}` },
}
);
expect(response.status).toBe(401);
expect(await readReservedMutationState()).toEqual(before);
} finally {
await accessTokenStore.revoke(foreign.accessToken);
}
});
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
const token = await createAccessToken('sse-query', {});
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
expect(queryOnly.status).toBe(401);
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
if (!foreign) throw new Error('SSE foreign token fixture failed');
try {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${foreign.accessToken}` },
});
expect(response.status).toBe(401);
const stream = await openRealtime(token);
stream.abort.abort();
} finally {
await accessTokenStore.revoke(token);
await accessTokenStore.revoke(foreign.accessToken);
}
});
it.each([
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
{
label: 'profile game restriction',
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
},
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
const token = await createAccessToken(`sse-${label}`, sanctions);
try {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
});
expect(response.status).toBe(401);
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
} finally {
await accessTokenStore.revoke(token);
}
});
it('SSE closes on idle expiry without forwarding another frame', async () => {
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
const created = await accessTokenStore.create(auth);
if (!created) throw new Error('SSE expiry token fixture failed');
const stream = await openRealtime(created.accessToken);
try {
expect(await stream.reader.read()).toMatchObject({ done: true });
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
} finally {
stream.abort.abort();
await accessTokenStore.revoke(created.accessToken);
}
});
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
const accessToken = await createAccessToken('sse-revoke', {});
const stream = await openRealtime(accessToken);
const channel = server!.config.profileName;
try {
const event = JSON.stringify({ type: 'tournamentChanged' });
await redis!.client.publish(buildGameEventChannel(channel), event);
const valid = await stream.reader.read();
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
await redis!.client.publish(buildGameEventChannel(channel), event);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(accessToken);
}
});
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
const sseUser = `security-http-sse-flush-${process.pid}`;
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
if (!created) throw new Error('SSE flush fixture failed');
const stream = await openRealtime(created.accessToken);
try {
await redis!.client.publish(
`${redisPrefix}:flush`,
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(created.accessToken);
}
});
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
const actor = `security-http-durable-missed-${process.pid}`;
const accessToken = await createAccessToken('durable-missed', {}, actor);
try {
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('retains persisted revocation state across an actual API restart', async () => {
const actor = `security-http-durable-restart-${process.pid}`;
const accessToken = await createAccessToken('durable-restart', {}, actor);
try {
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await server!.app.close();
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
method: 'POST',
input: { gatewayToken },
});
expect(exchanged.response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
const actor = `security-http-durable-sse-${process.pid}`;
const accessToken = await createAccessToken('durable-sse', {}, actor);
const stream = await openRealtime(accessToken);
try {
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await redis!.client.publish(
buildGameEventChannel(profileName),
JSON.stringify({ type: 'tournamentChanged' })
);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(accessToken);
}
});
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
const actor = `security-http-durable-cutoff-${process.pid}`;
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.set(key, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const fresh = await accessTokenStore.create({
...buildPayload('fresh-cutoff', {}, actor),
issuedAt: new Date(Date.now() + 1).toISOString(),
});
expect(fresh).not.toBeNull();
try {
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
200
);
} finally {
await accessTokenStore.revoke(fresh!.accessToken);
}
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
const actor = `security-http-durable-missing-${process.pid}`;
const accessToken = await createAccessToken('durable-missing', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.del(key);
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(sse.status).toBe(503);
await expect(createGameApiServer()).rejects.toThrow('startup refused');
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
// 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.
+99 -35
View File
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
this.commands.push(command);
if (command.type === 'adjustGeneralResources') {
const adjustment = command.adjustments[0];
if (!adjustment) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
if (command.type === 'tournamentAdjustGeneral') {
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
}
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
if (nextGold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
}
this.gold.set(adjustment.generalId, nextGold);
return {
type: 'adjustGeneralResources',
ok: true,
processed: 1,
missing: 0,
totalGoldDelta: adjustment.goldDelta ?? 0,
totalRiceDelta: 0,
};
this.gold.set(command.generalId, nextGold);
return { type: command.type, ok: true, generalId: command.generalId };
}
if (command.type === 'setMySetting') {
return { type: 'setMySetting', ok: true, generalId: command.generalId };
}
if (command.type === 'adjustGeneralMeta') {
if (this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
}
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
}
return null;
}
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
userId: 'user-1',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
generalId: general.id,
goldDelta: -64,
minGoldAfter: 0,
});
});
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
userId: 'user-1',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
betGoldDelta: 600,
})
);
});
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 800,
myTotals: { 12: 800 },
totalAmount: 1_800,
});
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 1_000,
myTotals: { 11: 200, 12: 800 },
});
expect(transport.gold.get(1)).toBe(2_800);
});
it('does not infer a legacy bet actor from the current general owner', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(1, 3_000);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
expect(transport.gold.get(1)).toBe(3_000);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
expect(snapshot.sourceRevision).toBe('41');
});
it('refunds gold when the tournament bet rank update fails', async () => {
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 2_000);
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
code: 'BAD_REQUEST',
});
expect(transport.gold.get(general.id)).toBe(2_000);
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
+55 -30
View File
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
sendCommand: async () => 'ok',
requestCommand: async () => null,
requestCommand: async (command) =>
command.type === 'tournamentSeedNpcBets'
? {
type: command.type,
ok: true,
processedGeneralIds: command.bets.map((bet) => bet.generalId),
skippedGeneralIds: [],
}
: null,
requestStatus: async () => null,
});
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5);
commands.push(command);
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
expect(commands).toEqual([
{
type: 'adjustGeneralResources',
requestId: 'tournament:123:npc-bet:resources',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
},
{
type: 'adjustGeneralMeta',
requestId: 'tournament:123:npc-bet:meta',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
type: 'tournamentSeedNpcBets',
requestId: 'tournament:123:npc-bet:atomic',
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
},
]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
expect(await store.getBettingEntries()).toEqual(bets);
expect(commands).toHaveLength(2);
expect(commands).toHaveLength(1);
// A fresh projection with the same seed and DB inputs has the same choices.
await store.setBettingEntries([bets[0]!]);
await seedNpcBets({
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toEqual(bets);
});
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
const state = createTournamentState({
stage: 5,
bettingId: 789,
npcBettingPlan: [
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
],
});
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
requestCommand: async (command) => {
expect(command.type).toBe('tournamentSeedNpcBets');
expect((await store.getState())?.stage).toBe(5);
expect(await store.getBettingEntries()).toEqual([]);
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
},
};
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
expect(opened.stage).toBe(6);
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
});
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 });
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toHaveLength(1);
});
it('reuses durable command identities after a partial enqueue failure', async () => {
it('reuses durable command identities after an uncertain atomic command response', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
const state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state);
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
let fail = true;
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) {
if (command.type === 'tournamentSeedNpcBets' && fail) {
fail = false;
// The accepted debit may already have committed before retry.
prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable');
}
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
);
expect(await store.getState()).toMatchObject({
stage: 5,
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
});
expect(await store.getBettingEntries()).toEqual([]);
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
expect(commands.map((command) => command.requestId)).toEqual([
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:atomic',
]);
expect(await store.getBettingEntries()).toHaveLength(1);
});
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
commands.push(command);
return 'ok';
},
requestCommand: async () => null,
requestCommand: async (command) => {
commands.push(command);
return createNoopDaemonTransport().requestCommand(command);
},
requestStatus: async () => null,
};
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
expect(commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
reason: 'tournamentNpcBet',
adjustments: expect.arrayContaining([
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
]),
type: 'tournamentSeedNpcBets',
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
})
);
}