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

This commit is contained in:
2026-10-23 01:33:42 +09:00
97 changed files with 5522 additions and 740 deletions
+13
View File
@@ -38,6 +38,15 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
WEB_PUSH_OUTBOX_POLL_MS=1000
GATEWAY_PUBLIC_URL=http://localhost:13000
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# GAME_PUBLIC_URL=http://localhost:14000
# Gateway authentication protection shares atomic 60s counters under this prefix.
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
# expensive auth work is capped at 4/process. Forwarded headers are not trusted.
# These are security defaults in auth/attemptBudget.ts, not disable-able env toggles.
GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
@@ -103,3 +112,7 @@ VITE_CASUAL_CHAT_URL=
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
TURN_BUILD_COMMIT_SHA=
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
RELEASE_BUILDER_URL=
RELEASE_BUILDER_TOKEN_FILE=
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+1 -1
View File
@@ -87,7 +87,7 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
## 도구 체인
- Node.js `24.21.0`, pnpm `12.5.1`, Turbo
- Node.js `24.22.1`, pnpm `12.5.1`, Turbo
- TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc`
- Vue CLI 전용 `typescript-native-bridge` (`6.0.3-bridge.18.tsgo.7.0.2`)
- Fastify, tRPC, zod
+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) })]),
})
);
}
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
select: {
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
clockTick: true,
meta: true,
},
});
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
]),
});
const zTournamentAdjustGeneral = z.object({
type: z.literal('tournamentAdjustGeneral'),
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
userId: z.string().min(1),
generalId: zSafeInteger.positive(),
goldDelta: zFiniteNumber,
betGoldDelta: zFiniteNumber.optional(),
minGoldAfter: zFiniteNumber.nonnegative(),
});
const zTournamentSeedNpcBets = z.object({
type: z.literal('tournamentSeedNpcBets'),
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
});
const zAdjustGeneralResources = z.object({
type: z.literal('adjustGeneralResources'),
reason: z.string().optional(),
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
const command = parseWith(zAdjustGeneralResources, envelope.command);
if (!command) {
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
voteReward: normalizeVoteReward,
setNationSetting: normalizeSetNationSetting,
setNpcPolicy: normalizeSetNpcPolicy,
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
adjustGeneralResources: normalizeAdjustGeneralResources,
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
};
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'tournamentAdjustGeneral',
'troopCreate',
'troopJoin',
'troopExit',
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
}
async function handleTournamentAdjustGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
): Promise<TurnDaemonCommandResult> {
const general = ctx.world.getGeneralById(command.generalId);
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
if (!general || general.userId !== command.userId) {
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
}
const gold = general.gold + command.goldDelta;
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
}
const meta = { ...general.meta };
if (command.betGoldDelta !== undefined) {
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
const betgold = current + command.betGoldDelta;
if (!Number.isFinite(betgold) || betgold < 0) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
}
meta.betgold = betgold;
}
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
ctx.world.updateGeneral(command.generalId, { gold, meta });
return { type: command.type, ok: true, generalId: command.generalId };
}
async function handleTournamentSeedNpcBets(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
): Promise<TurnDaemonCommandResult> {
if (ctx.commandDb) {
const event = command.requestId
? await ctx.commandDb.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true, target: true, eventType: true },
})
: null;
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
}
}
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
}
const processedGeneralIds: number[] = [];
const skippedGeneralIds: number[] = [];
for (const bet of command.bets) {
const general = ctx.world.getGeneralById(bet.generalId);
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
skippedGeneralIds.push(bet.generalId);
continue;
}
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
ctx.world.updateGeneral(bet.generalId, {
gold: general.gold - bet.amount,
meta: { ...general.meta, betgold: current + bet.amount },
});
processedGeneralIds.push(bet.generalId);
}
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
}
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
reason !== undefined &&
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
reason
);
async function handleAdjustGeneralResources(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
}
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return {
type: 'adjustGeneralMeta',
@@ -3269,6 +3346,13 @@ export const createTurnDaemonCommandHandler = (options: {
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) =>
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
tournamentAdjustGeneral: (command) =>
handleTournamentAdjustGeneral(
ctx,
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
),
tournamentSeedNpcBets: (command) =>
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
adjustGeneralResources: (command) =>
handleAdjustGeneralResources(
ctx,
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
{
type: 'tournamentAdjustGeneral',
reason: 'tournamentBet',
requestId: 'tournamentAdjustGeneral',
userId,
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
@@ -274,69 +274,79 @@ integration('database command queue', () => {
});
});
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
const requestId = 'integration:engine:stale-owner-replay';
const command: TurnDaemonCommand = {
type: 'vacation',
requestId,
userId: 'old-owner',
generalId: 7,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
'stores a stale-owner %s rejection once and never redispatches it',
async (type) => {
const requestId = `integration:engine:stale-owner-replay:${type}`;
const command: TurnDaemonCommand =
type === 'vacation'
? { type, requestId, userId: 'old-owner', generalId: 7 }
: {
type,
reason: 'tournamentBet',
requestId,
userId: 'old-owner',
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
}
);
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns',
@@ -630,6 +640,8 @@ integration('database command queue', () => {
const metaId = 'integration:engine:suspended-tournament-bet-meta';
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
const atomicBetId = 'integration:engine:suspended-atomic-bet';
const atomicJoinId = 'integration:engine:suspended-atomic-join';
await db.inputEvent.createMany({
data: [
{
@@ -679,6 +691,41 @@ integration('database command queue', () => {
],
});
await db.inputEvent.createMany({
data: [
{
requestId: atomicBetId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentBet',
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
},
{
requestId: atomicJoinId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicJoinId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentJoin',
goldDelta: -200,
minGoldAfter: 0,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
@@ -688,7 +735,15 @@ integration('database command queue', () => {
requestId: rollbackId,
reason: 'tournamentBetRollback',
}),
expect.objectContaining({
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
reason: 'tournamentBet',
}),
]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
status: 'PENDING',
});
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
status: 'PROCESSING',
processingGameTick: 321n,
@@ -79,6 +79,83 @@ const buildWorld = (
return new InMemoryTurnWorld(state, snapshot, { schedule });
};
describe('tournament mutation ownership', () => {
it('checks the current owner and commits gold plus bet statistics together', async () => {
const general = { ...buildGeneral(1), userId: 'owner' };
const world = buildWorld([general]);
const handler = createTurnDaemonCommandHandler({ world });
const command = {
type: 'tournamentAdjustGeneral' as const,
reason: 'tournamentBet' as const,
userId: 'owner',
generalId: 1,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
const before = structuredClone(world.getGeneralById(1));
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
ok: false,
code: 'BAD_REQUEST',
});
expect(world.getGeneralById(1)).toEqual(before);
world.updateGeneral(1, { userId: 'new-owner' });
const transferred = structuredClone(world.getGeneralById(1));
for (const goldDelta of [-100, 100]) {
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
ok: false,
code: 'FORBIDDEN',
});
expect(world.getGeneralById(1)).toEqual(transferred);
}
});
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
const world = buildWorld([
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
buildGeneral(2, {}, 2),
{ ...buildGeneral(3, {}, 2), gold: 509 },
]);
const handler = createTurnDaemonCommandHandler({ world });
const possessed = structuredClone(world.getGeneralById(1));
await expect(
handler.handle({
type: 'tournamentSeedNpcBets',
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
})
).resolves.toEqual({
type: 'tournamentSeedNpcBets',
ok: true,
processedGeneralIds: [2],
skippedGeneralIds: [1, 3],
});
expect(world.getGeneralById(1)).toEqual(possessed);
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
});
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
'rejects actorless legacy %s commands before mutation',
async (reason) => {
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
const before = structuredClone(world.getGeneralById(1));
const handler = createTurnDaemonCommandHandler({ world });
for (const command of [
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
{
type: 'adjustGeneralMeta' as const,
reason,
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
},
])
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
expect(world.getGeneralById(1)).toEqual(before);
}
);
});
describe('tournament world commands', () => {
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
const currentWorld = buildWorld([], {
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
});
it('records all four tournament types and NPC betting for at least ten generals', async () => {
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
const world = buildWorld(generals);
const handler = createTurnDaemonCommandHandler({ world });
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
}
}
await handler.handle({
type: 'adjustGeneralMeta',
reason: 'tournamentNpcBet',
adjustments: generals.map((general) => ({
generalId: general.id,
metaDelta: { betgold: 1_000 },
})),
type: 'tournamentSeedNpcBets',
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
});
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
await handler.handle({
type: 'tournamentBettingPayout',
bettingId: 1,
+104 -49
View File
@@ -880,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
const installRealtimeHarness = async (page: Page) => {
await page.addInitScript(() => {
class TestEventSource extends EventTarget {
static latest: TestEventSource | null = null;
static created = 0;
static closed = 0;
readonly url: string;
constructor(url: string | URL) {
super();
this.url = url.toString();
TestEventSource.created += 1;
TestEventSource.latest = this;
queueMicrotask(() => this.dispatchEvent(new Event('open')));
}
close() {
if (TestEventSource.latest === this) {
TestEventSource.latest = null;
TestEventSource.closed += 1;
}
}
}
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource });
Object.defineProperty(window, '__emitMainRealtime', {
configurable: true,
value: (type: string, payload: unknown) => {
TestEventSource.latest?.dispatchEvent(
new MessageEvent(type, {
data: JSON.stringify({
type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' }
: {}),
...((payload as object) ?? {}),
}),
})
);
const originalFetch = window.fetch.bind(window);
let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
let created = 0;
let closed = 0;
let authenticated = false;
let credentialInUrl = false;
window.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
credentialInUrl = url.searchParams.has('token');
let controller!: ReadableStreamDefaultController<Uint8Array>;
let ended = false;
const source = {
close: () => {
if (ended) return;
ended = true;
closed += 1;
if (latest === source) latest = null;
controller.close();
},
emit: (type: string, payload: unknown) => {
if (ended) return;
controller.enqueue(
new TextEncoder().encode(
`event: ${type}\ndata: ${JSON.stringify({
type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' }
: {}),
...((payload as object) ?? {}),
})}\n\n`
)
);
},
};
const body = new ReadableStream<Uint8Array>({
start: (next) => {
controller = next;
},
cancel: () => {
if (!ended) {
ended = true;
closed += 1;
if (latest === source) latest = null;
}
},
});
created += 1;
latest = source;
init?.signal?.addEventListener('abort', source.close, { once: true });
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
};
Object.assign(window, {
__emitMainRealtime: (type: string, payload: unknown) => {
latest?.emit(type, payload);
},
});
Object.defineProperty(window, '__hasMainRealtime', {
configurable: true,
value: () => TestEventSource.latest !== null,
});
Object.defineProperty(window, '__mainRealtimeStats', {
configurable: true,
value: () => ({
active: TestEventSource.latest !== null,
created: TestEventSource.created,
closed: TestEventSource.closed,
}),
__hasMainRealtime: () => latest !== null,
__mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
});
});
};
@@ -5430,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
expect(
await page.evaluate(() =>
(
window as unknown as {
__mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean };
}
).__mainRealtimeStats()
)
).toMatchObject({ authenticated: true, credentialInUrl: false });
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null);
const captureRefreshLayout = async (phase: string): Promise<void> => {
if (!autoRefreshArtifactRoot) return;
await page.evaluate(() => document.fonts.ready);
const layout = await page.evaluate(() => ({
url: location.href,
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map(
(element) => {
const style = getComputedStyle(element);
return {
target: element.getAttribute('data-main-target'),
bounds: element.getBoundingClientRect().toJSON(),
fontFamily: style.fontFamily,
fontSize: style.fontSize,
color: style.color,
backgroundColor: style.backgroundColor,
display: style.display,
};
}
),
images: Array.from(document.images).map((image) => ({
src: image.getAttribute('src'),
width: image.naturalWidth,
height: image.naturalHeight,
complete: image.complete,
objectFit: getComputedStyle(image).objectFit,
})),
}));
await Promise.all([
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)),
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()),
]);
};
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
@@ -5473,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
}).observe({ entryTypes: ['measure'] });
});
await captureRefreshLayout('before-refresh');
const callsBeforeRefresh = state.generalMeCalls;
const operationsBeforeClockOnly = state.operations.length;
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -5616,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
};
});
await captureRefreshLayout('after-refresh');
expect(profile.generalMounted).toBe(true);
expect(profile.cityMounted).toBe(true);
expect(profile.generalMutations).toBeGreaterThan(0);
+61 -29
View File
@@ -371,37 +371,57 @@ const installFixture = async (
const installFakeEventSource = async (page: Page) => {
await page.addInitScript(() => {
class FakeEventSource extends EventTarget {
static instances: FakeEventSource[] = [];
readonly url: string;
closed = false;
constructor(url: string | URL) {
super();
this.url = String(url);
FakeEventSource.instances.push(this);
queueMicrotask(() => {
if (!this.closed) this.dispatchEvent(new Event('open'));
});
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
if (this.closed) return;
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
const originalFetch = window.fetch.bind(window);
const sources: Array<{
url: string;
closed: boolean;
authenticated: boolean;
emit: (type: string, payload: unknown) => void;
}> = [];
window.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
let controller!: ReadableStreamDefaultController<Uint8Array>;
const source = {
url: url.href,
closed: false,
authenticated: new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false,
emit: (type: string, payload: unknown) => {
if (!source.closed)
controller.enqueue(
new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
);
},
};
const body = new ReadableStream<Uint8Array>({
start: (next) => {
controller = next;
},
cancel: () => {
source.closed = true;
},
});
sources.push(source);
init?.signal?.addEventListener(
'abort',
() => {
if (!source.closed) {
source.closed = true;
controller.close();
}
},
{ once: true }
);
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
};
Object.assign(window, {
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () =>
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
__tournamentEventSourceCount: () => sources.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () => sources.filter((source) => !source.closed).map((source) => source.url),
__tournamentEventSourceAuthenticated: () =>
sources.filter((source) => !source.closed).every((source) => source.authenticated),
__emitTournamentEvent: (type: string, payload: unknown) => {
for (const source of FakeEventSource.instances) source.emit(type, payload);
for (const source of sources) source.emit(type, payload);
},
});
});
@@ -885,6 +905,18 @@ test('betting realtime refresh is shared across tabs and preserves local interac
).flat();
expect(activeSourceUrls).toHaveLength(1);
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
expect(new URL(activeSourceUrls[0]!).searchParams.has('token')).toBe(false);
for (const candidate of [page, follower]) {
expect(
await candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceAuthenticated: () => boolean;
}
).__tournamentEventSourceAuthenticated()
)
).toBe(true);
}
const before = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
+1
View File
@@ -49,6 +49,7 @@
"chart.js": "4.5.1",
"date-fns": "^4.1.0",
"es-toolkit": "^1.43.0",
"eventsource": "5.1.2",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"tippy.js": "6.3.7",
+12 -5
View File
@@ -1,3 +1,4 @@
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
import { receiveClockEngineState } from '../composables/useClockDisplay';
import { computed, ref, toRaw, watch } from 'vue';
import { defineStore } from 'pinia';
@@ -1139,16 +1140,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
};
let realtimeSource: EventSource | null = null;
let realtimeSource: AuthenticatedEventSource | null = null;
let realtimeToken: string | null = null;
let visibilityListenerInstalled = false;
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
const buildRealtimeUrl = (token: string): string => {
const buildRealtimeUrl = (): string => {
const base = gameFrontendRuntimeConfig.gameSseUrl;
const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
return url.toString();
};
@@ -1284,15 +1284,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeToken = token;
realtimeStatus.value = 'idle';
const source = new EventSource(buildRealtimeUrl(token));
let needsRecovery = false;
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
realtimeSource = source;
source.addEventListener('open', () => {
markGameServerContact();
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
if (needsRecovery) {
needsRecovery = false;
realtimeRefreshQueue.request();
}
});
source.addEventListener('error', () => {
source.addEventListener('error', (event) => {
if (event.code === 401) void session.refreshGeneralStatus();
needsRecovery = true;
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
});
@@ -1,3 +1,4 @@
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
@@ -49,7 +50,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
const realtimeStatus = ref<'idle' | 'connected'>('idle');
let activeConsumers = 0;
let realtimeSource: EventSource | null = null;
let realtimeSource: AuthenticatedEventSource | null = null;
let realtimeToken: string | null = null;
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
let realtimeCoordinatorScope: string | null = null;
@@ -136,10 +137,9 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
if (!(await session.exchangeGatewayToken())) return null;
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
};
const buildRealtimeUrl = (token: string): string => {
const buildRealtimeUrl = (): string => {
const base = gameFrontendRuntimeConfig.gameSseUrl;
const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
url.searchParams.set('scope', 'tournament');
return url.toString();
};
@@ -181,7 +181,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
if (realtimeSource && realtimeToken === token) return;
closeRealtimeSource();
realtimeToken = token;
const source = new EventSource(buildRealtimeUrl(token));
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
realtimeSource = source;
source.addEventListener('open', () => {
realtimeStatus.value = 'connected';
@@ -196,7 +196,8 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
});
}
});
source.addEventListener('error', () => {
source.addEventListener('error', (event) => {
if (event.code === 401) void session.refreshGeneralStatus();
needsRecovery = true;
realtimeStatus.value = 'idle';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
@@ -0,0 +1,26 @@
import { EventSource } from 'eventsource';
export type AuthenticatedEventSource = EventSource;
/** Keep the access token out of browser/proxy URLs and ambient cookies. */
export const createAuthenticatedEventSource = (url: string, accessToken: string): EventSource => {
const destination = new URL(url);
if (destination.searchParams.has('token') || destination.username || destination.password) {
throw new Error('Realtime credentials must use the authorization header.');
}
return new EventSource(destination, {
maxBufferSize: 64 * 1024,
fetch: (input, init) => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${accessToken}`);
return fetch(input, {
...init,
headers,
credentials: 'omit',
redirect: 'error',
cache: 'no-store',
referrerPolicy: 'no-referrer',
});
},
});
};
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import test from 'node:test';
import { createAuthenticatedEventSource } from '../src/utils/authenticatedEventSource.ts';
const listen = (server: Server): Promise<string> =>
new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
resolve(`http://127.0.0.1:${address.port}`);
});
});
const stop = (server: Server): Promise<void> =>
new Promise((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
void test(
'reconnects with header auth, omits ambient credentials and preserves named UTF-8 SSE events',
{ timeout: 5_000 },
async () => {
const requests: Array<{ url: string; authorization: string | undefined; cookie: string | undefined }> = [];
const server = createServer((request, response) => {
requests.push({
url: request.url!,
authorization: request.headers.authorization,
cookie: request.headers.cookie,
});
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.write(
`retry: 20\nevent: update\ndata: ${JSON.stringify({ value: '갱신', attempt: requests.length })}\n\n`
);
if (requests.length === 1) response.end();
});
const origin = await listen(server);
const source = createAuthenticatedEventSource(`${origin}/events?scope=tournament`, 'ga_synthetic-fixture');
try {
const events: unknown[] = [];
await new Promise<void>((resolve) =>
source.addEventListener('update', (event) => {
events.push(JSON.parse(event.data));
if (events.length === 2) resolve();
})
);
assert.deepEqual(events, [
{ value: '갱신', attempt: 1 },
{ value: '갱신', attempt: 2 },
]);
assert.deepEqual(
requests,
Array.from({ length: 2 }, () => ({
url: '/events?scope=tournament',
authorization: 'Bearer ga_synthetic-fixture',
cookie: undefined,
}))
);
} finally {
source.close();
await stop(server);
}
}
);
void test('401 is terminal and redirects never forward the authorization header', { timeout: 5_000 }, async () => {
let redirectedRequests = 0;
const target = createServer((_req, res) => {
redirectedRequests += 1;
res.writeHead(200);
res.end();
});
const targetOrigin = await listen(target);
const server = createServer((req, res) => {
res.writeHead(req.url === '/unauthorized' ? 401 : 302, { location: targetOrigin });
res.end();
});
const origin = await listen(server);
try {
for (const endpoint of ['unauthorized', 'redirect']) {
const source = createAuthenticatedEventSource(`${origin}/${endpoint}`, 'ga_synthetic-fixture');
try {
const code = await new Promise<number | undefined>((resolve) =>
source.addEventListener('error', (event) => resolve(event.code))
);
if (endpoint === 'unauthorized') {
assert.equal(code, 401);
assert.equal(source.readyState, source.CLOSED);
}
} finally {
source.close();
}
}
assert.equal(redirectedRequests, 0);
} finally {
await stop(server);
await stop(target);
}
});
void test('rejects configured query or URL credentials before connecting', () => {
assert.throws(() => createAuthenticatedEventSource('http://localhost/events?token=synthetic', 'ga_synthetic'));
assert.throws(() => createAuthenticatedEventSource('http://user:synthetic@localhost/events', 'ga_synthetic'));
});
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface UserIconUploadResult {
picture: string;
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
}
const signature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
export class RemoteUserIconStore implements UserIconUploadStore {
constructor(
private readonly baseUrl: string,
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid user icon filename.');
}
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
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(
this.secret,
expires,
requestId,
pathname,
input.contentType,
input.body
),
await performSignedImageUpload(
{
...input,
kind: 'user-icons',
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}.`);
}
this.fetchImpl,
this.now
);
const picture = `users/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
throw new Error('Image repository returned an unexpected upload path.');
}
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
}
}
+92 -44
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1);
const iconImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 128 * 128 * 256;
const MAX_ICON_BYTES = 50 * 1024;
const MAX_ACTIVE_ICONS = 5;
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
@@ -265,52 +274,91 @@ export const accountRouter = router({
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
try {
return await iconImageBudget.run(async () => {
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
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: '이미지 프레임 제한을 초과했습니다.' });
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (
!metadata.width ||
metadata.width < 64 ||
metadata.width > 128 ||
metadata.height !== metadata.width
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
const stored = await ctx.users.addIconForWindow(
user.id,
uploaded.picture,
0,
now,
MAX_ACTIVE_ICONS
);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
});
} 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;
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
}),
setPreferredIcon: procedure
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
+196
View File
@@ -0,0 +1,196 @@
import { createHmac } from 'node:crypto';
export interface AuthCounter {
key: string;
limit: number;
windowMs: number;
}
export interface AuthCounterStore {
readonly available?: boolean;
/** Atomically check every counter, then charge them only when accepted. */
consume(counters: AuthCounter[]): Promise<number>;
}
export class AuthBudgetError extends Error {
constructor(
readonly reason: 'limited' | 'unavailable',
readonly retryAfterSeconds = 1
) {
super(
reason === 'limited'
? 'Authentication attempts are temporarily limited.'
: 'Authentication protection is unavailable.'
);
}
}
const CONSUME_SCRIPT = `
local retry = 0
for i, key in ipairs(KEYS) do
local count = tonumber(redis.call('GET', key) or '0')
if count >= tonumber(ARGV[i * 2 - 1]) then
local ttl = redis.call('PTTL', key)
if ttl < 0 then
return -1
end
retry = math.max(retry, math.max(ttl, 1))
end
end
if retry > 0 then return retry end
for i, key in ipairs(KEYS) do
local count = redis.call('INCR', key)
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
end
return 0
`;
export class RedisAuthCounterStore implements AuthCounterStore {
constructor(
private readonly client: {
readonly isReady?: boolean;
withAbortSignal?: (signal: AbortSignal) => {
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
};
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
}
) {}
get available(): boolean {
return this.client.isReady !== false;
}
async consume(counters: AuthCounter[]): Promise<number> {
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
const result = await client.eval(CONSUME_SCRIPT, {
keys: counters.map((counter) => counter.key),
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
});
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
throw new AuthBudgetError('unavailable');
}
return result;
}
}
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
export class InMemoryAuthCounterStore implements AuthCounterStore {
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
async consume(counters: AuthCounter[]): Promise<number> {
const now = Date.now();
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
throw new AuthBudgetError('unavailable');
}
let retryMs = 0;
for (const counter of counters) {
const entry = this.entries.get(counter.key);
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
}
if (retryMs > 0) return retryMs;
for (const counter of counters) {
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
entry.count += 1;
this.entries.set(counter.key, entry);
}
return 0;
}
}
const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal',
'auth.kakaoExchange',
'auth.kakaoResolveAccount',
'auth.kakaoSetPassword',
'auth.register',
'auth.registerLocal',
'auth.login',
'auth.kakaoOtp',
'account.changePassword',
'account.scheduleDeletion',
]);
const LIGHTWEIGHT_ACTIONS = new Set([
'auth.passwordKey',
'auth.checkRegistrationField',
'auth.kakaoStart',
'auth.issueGameSession',
]);
export const isBudgetedAuthAction = (action: string): boolean =>
EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action);
export class AuthAttemptBudget {
private active = 0;
private pendingChecks = 0;
constructor(
private readonly store: AuthCounterStore,
private readonly prefix: string,
private readonly secret: string
) {}
private key(kind: string, value: string): string {
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
}
async run<T>(
action: string,
client: string,
subject: string | undefined | (() => Promise<string | undefined>),
work: () => Promise<T>
): Promise<T> {
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
const expensive = EXPENSIVE_ACTIONS.has(action);
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
if (expensive) this.active += 1;
try {
const consume = async (): Promise<number> => {
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
const counters: AuthCounter[] = [
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
];
if (resolvedSubject)
counters.push({
key: this.key('subject', resolvedSubject),
limit: action === 'auth.issueGameSession' ? 32 : 10,
windowMs: 60_000,
});
return this.store.consume(counters);
};
this.pendingChecks += 1;
const checking = consume();
// A caller timeout does not prove the underlying Redis/actor read settled.
void checking.then(
() => {
this.pendingChecks -= 1;
},
() => {
this.pendingChecks -= 1;
}
);
let timeout: ReturnType<typeof setTimeout> | undefined;
let retryMs: number;
try {
retryMs = await Promise.race([
checking,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
timeout.unref();
}),
]);
} catch {
throw new AuthBudgetError('unavailable');
} finally {
clearTimeout(timeout);
}
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
return await work();
} finally {
if (expensive) this.active -= 1;
}
}
}
+52 -3
View File
@@ -1,3 +1,28 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
interface RedisClientLike {
readonly isReady?: boolean;
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
withAbortSignal?(signal: AbortSignal): RedisClientLike;
}
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
const PUBLISH_FLUSH_SCRIPT = `
redis.call('SET', KEYS[1], ARGV[1], 'NX')
local raw = redis.call('GET', KEYS[2])
local prior = tonumber(raw)
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
redis.call('PUBLISH', ARGV[2], ARGV[3])
return 1
`;
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
private readonly channel: string;
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
private readonly client: RedisClientLike;
private readonly commands = new SessionRevocationCommandBudget(32);
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
constructor(client: RedisClientLike, channel: string) {
this.client = client;
this.channel = channel;
}
private commandClient(): RedisClientLike {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async initialize(): Promise<void> {
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
await this.commands.run(() =>
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
NX: true,
})
);
}
async publishUserFlush(
userId: string,
reason?: string,
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
};
await this.client.publish(this.channel, JSON.stringify(payload));
const result = await this.commands.run(() =>
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
keys: [
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
],
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
})
);
if (result !== 1) throw new Error('Session revocation persistence failed.');
}
}
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
import type {
GameSessionInfo,
@@ -60,7 +61,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
if (Date.now() >= stored.expiresAt) {
this.sessions.delete(sessionToken);
this.sessionGames.delete(sessionToken);
return null;
@@ -86,9 +87,19 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
const parent = this.sessions.get(sessionToken);
if (!session || !parent || parent.info !== session || parent.expiresAt <= Date.now()) {
return null;
}
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
for (const key of set) {
const stored = this.gameSessions.get(key);
if (!stored || stored.expiresAt <= Date.now()) {
this.gameSessions.delete(key);
set.delete(key);
}
}
if (set.size >= MAX_GAME_SESSIONS_PER_SESSION) throw new GameSessionLimitError();
const gameToken = randomUUID();
const info: GameSessionInfo = {
profile,
@@ -109,7 +120,6 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
info,
expiresAt: Date.now() + this.gameSessionTtlMs,
});
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
set.add(key);
this.sessionGames.set(sessionToken, set);
return info;
@@ -121,7 +131,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
if (Date.now() >= stored.expiresAt) {
this.gameSessions.delete(key);
return null;
}
+56 -15
View File
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
redirectUri: string;
oauthHost?: string;
apiHost?: string;
requestTimeoutMs?: number;
}
export interface KakaoOAuthToken {
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
private readonly redirectUri: string;
private readonly oauthHost: string;
private readonly apiHost: string;
private readonly requestTimeoutMs: number;
constructor(config: KakaoOAuthConfig) {
this.restKey = config.restKey;
this.redirectUri = config.redirectUri;
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
}
}
private async requestJson(
url: URL,
init: RequestInit
): Promise<{ response: Response; payload: Record<string, unknown> }> {
try {
const response = await fetch(url, {
...init,
redirect: 'error',
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
if (!response.body) throw new Error('Missing response body.');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024) {
void reader.cancel().catch(() => undefined);
throw new Error('Response is too large.');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
);
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
throw new Error('Invalid response shape.');
return { response, payload: payload as Record<string, unknown> };
} catch {
// Provider/network payloads can include access tokens or account details.
throw new Error('Kakao HTTP request failed.');
}
}
buildAuthUrl(state: string, scopes: string[]): string {
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
}
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
code,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth token error.');
}
return parseToken(payload);
}
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
refresh_token: refreshToken,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth refresh error.');
}
return parseToken(payload);
}
async signup(accessToken: string): Promise<KakaoSignupResult> {
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
return {
alreadyRegistered: true,
};
}
if (!response.ok) {
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
throw new Error('Kakao signup error.');
}
return {
id: payload.id ? String(payload.id) : undefined,
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
}
async getMe(accessToken: string): Promise<KakaoUserInfo> {
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
throw new Error('Kakao me error.');
}
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
return {
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
}
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
}),
}),
});
const payload = (await response.json()) as Record<string, unknown>;
const code = Number(payload.code ?? 0);
if (!response.ok || code < 0) {
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
throw new Error('Kakao talk message error.');
}
}
}
+53 -31
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
import type {
GameSessionInfo,
GatewaySessionConfig,
@@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig {
keyPrefix: string;
}
interface RedisPipeline {
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
sAdd(key: string, member: string): RedisPipeline;
expire(key: string, seconds: number): RedisPipeline;
del(key: string): RedisPipeline;
exec(): Promise<unknown>;
}
interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
sMembers(key: string): Promise<string[]>;
multi(): RedisPipeline;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
del(key: string): Promise<number>;
}
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다.
const CREATE_GAME_SESSION_SCRIPT = `
local parentTtl = redis.call('PTTL', KEYS[1])
if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
local limit = tonumber(ARGV[4])
if redis.call('SCARD', KEYS[2]) > limit then return -1 end
for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do
if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end
end
if redis.call('SCARD', KEYS[2]) >= limit then return -1 end
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3])
redis.call('SADD', KEYS[2], KEYS[3])
redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000))
return 1
`;
// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다.
const REVOKE_GAME_SESSION_BATCH_SCRIPT = `
redis.call('DEL', KEYS[1])
local entries = redis.call('SPOP', KEYS[2], 64)
for _, entry in ipairs(entries) do redis.call('DEL', entry) end
return #entries
`;
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
export class RedisGatewaySessionService implements GatewaySessionService {
private readonly client: RedisClientLike;
@@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService {
options: SessionRevocationOptions = { revokeGames: true }
): Promise<void> {
const key = this.keys.sessionKey(sessionToken);
if (options.revokeGames ?? true) {
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
const games = await this.client.sMembers(gameSetKey);
if (games.length > 0) {
const pipeline = this.client.multi();
for (const entry of games) {
pipeline.del(entry);
}
pipeline.del(gameSetKey);
pipeline.del(key);
await pipeline.exec();
return;
}
await this.client.del(gameSetKey);
if (!(options.revokeGames ?? true)) {
await this.client.del(key);
return;
}
// 부모 제거와 각 64개 삭제를 원자적으로 처리한다. 이후 발급 Lua는 실패한다.
// 이전 버전에서 커진 index도 응답/transaction 한 번에 전부 펼치지 않는다.
for (;;) {
const count = await this.client.eval(REVOKE_GAME_SESSION_BATCH_SCRIPT, {
keys: [key, this.keys.sessionGameSetKey(sessionToken)],
arguments: [],
});
if (typeof count !== 'number' || count < 0 || count > 64)
throw new Error('Unexpected game session revocation result.');
if (count < 64) break;
}
await this.client.del(key);
}
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
@@ -115,12 +131,18 @@ export class RedisGatewaySessionService implements GatewaySessionService {
};
const gameKey = this.keys.gameSessionKey(profile, gameToken);
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
await this.client
.multi()
.set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds })
.sAdd(gameSetKey, gameKey)
.expire(gameSetKey, this.sessionTtlSeconds)
.exec();
const result = await this.client.eval(CREATE_GAME_SESSION_SCRIPT, {
keys: [this.keys.sessionKey(sessionToken), gameSetKey, gameKey],
arguments: [
JSON.stringify(session),
JSON.stringify(info),
String(this.gameSessionTtlSeconds),
String(MAX_GAME_SESSIONS_PER_SESSION),
],
});
if (result === 0) return null;
if (result === -1) throw new GameSessionLimitError();
if (result !== 1) throw new Error('Unexpected game session admission result.');
return info;
}
@@ -1,5 +1,13 @@
import type { UserRecord, UserSanctions } from './userRepository.js';
// 한 Gateway 로그인에서 여러 profile/탭을 사용하되 추적 자료는 유한하게 유지한다.
export const MAX_GAME_SESSIONS_PER_SESSION = 256;
export class GameSessionLimitError extends Error {
constructor() {
super('Game session limit reached.');
}
}
export interface GatewaySessionInfo {
sessionToken: string;
userId: string;
+3 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
export interface GatewayApiConfig {
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
corsAllowedOrigins: string[];
userIconDir: string;
userIconPublicUrl: string;
imageUploadBaseUrl: string;
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
+9
View File
@@ -1,3 +1,4 @@
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
import type { GatewaySessionService } from './auth/sessionService.js';
import type { UserRepository } from './auth/userRepository.js';
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
releases: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget: AuthAttemptBudget;
requestIp: string;
requestHeaders: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
releases?: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget?: AuthAttemptBudget;
requestIp?: string;
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
orchestrator: options.orchestrator,
profileStatus: options.profileStatus,
authBudget:
options.authBudget ??
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
requestIp: options.requestIp ?? 'unknown',
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
+151 -40
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
export interface BuildCommand {
@@ -33,7 +34,8 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
const RELEASE_BUILD_ENV_NAME =
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
error?: string;
}
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
const record = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
if (!record(value)) return false;
if (Object.keys(value).length !== 1) return false;
if (value.result) {
const result = value.result;
return (
record(result) &&
typeof result.ok === 'boolean' &&
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
typeof result.output === 'string' &&
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
(result.aborted === undefined || typeof result.aborted === 'boolean')
);
}
if (typeof value.error === 'string') return value.error.length <= 2000;
if (!record(value.event)) return false;
const event = value.event;
if (event.type === 'OUTPUT')
return (
typeof event.stream === 'string' &&
['stdout', 'stderr'].includes(event.stream) &&
typeof event.message === 'string' &&
event.message.length <= 2000
);
if (
typeof event.type !== 'string' ||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
!record(event.command)
)
return false;
const command = event.command;
if (
typeof command.command !== 'string' ||
!['pnpm', 'node'].includes(command.command) ||
typeof command.cwd !== 'string' ||
command.cwd.length > 2048 ||
!Array.isArray(command.args) ||
command.args.length > 40 ||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
)
return false;
if (
command.env !== undefined &&
(!record(command.env) ||
Object.keys(command.env).length > 32 ||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
)
return false;
return (
event.type === 'COMMAND_START' ||
event.exitCode === null ||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
);
};
const readRemoteBuildToken = (filename: string | undefined): string => {
try {
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
const token = readFileSync(filename, 'utf8').trim();
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
return token;
} catch {
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
}
};
export interface RemoteBuildOptions {
tokenFile?: string;
timeoutMs?: number;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
private readonly timeoutMs: number;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
constructor(
baseUrl: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly config: RemoteBuildOptions = {}
) {
const parsed = new URL('/v1/builds', baseUrl);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error('Invalid release builder endpoint.');
}
this.endpoint = parsed.toString();
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
throw new Error('Invalid release builder deadline.');
}
}
async run(
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
timeout.unref();
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const token = readRemoteBuildToken(this.config.tokenFile);
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
redirect: 'error',
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
}),
signal: options?.signal,
signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
await response.body?.cancel().catch(() => undefined);
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
}
const decoder = new TextDecoder();
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
}
if (result.result) return result.result;
if (result.error) {
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) {
buffer += decoder.decode();
break;
}
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
buffer += decoder.decode(chunk.value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
if (result.result) return result.result;
if (result.error)
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, 'Release builder closed without a result.'),
};
} catch {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
),
...(aborted ? { aborted: true } : {}),
};
} finally {
clearTimeout(timeout);
controller.abort();
await reader?.cancel().catch(() => undefined);
reader?.releaseLock();
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
if (!line.trim()) return {};
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
return { error: 'Release builder frame is too large.' };
let message: unknown;
try {
message = JSON.parse(line) as RemoteBuildMessage;
message = JSON.parse(line);
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
if (message.event && onProgress) await onProgress(message.event);
return message;
}
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
fetchImpl: typeof fetch = fetch,
tokenFile?: string
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
@@ -1024,7 +1024,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
options.fetchImpl ?? fetch,
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
);
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
+15 -3
View File
@@ -22,6 +22,7 @@ import {
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
import { GameSessionLimitError } from './auth/sessionService.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
@@ -1048,11 +1049,13 @@ export const appRouter = router({
issueGameSession: procedure
.input(
z.object({
sessionToken: z.string().min(1),
sessionToken: z.string().min(1).max(256),
profile: zProfile,
})
)
.mutation(async ({ ctx, input }) => {
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
const authorizationStartedAt = Date.now();
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
if (!gatewaySession) {
throw new TRPCError({
@@ -1089,14 +1092,23 @@ export const appRouter = router({
message: '카카오 인증 유예기간이 만료되었습니다. 인증 후 계속 이용할 수 있습니다.',
});
}
const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile);
const gameSession = await ctx.sessions
.createGameSession(input.sessionToken, input.profile)
.catch((error: unknown) => {
if (!(error instanceof GameSessionLimitError)) throw error;
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '게임 세션이 많습니다. 기존 세션이 만료된 뒤 다시 시도해 주세요.',
});
});
if (!gameSession) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Session is not valid.',
});
}
const now = new Date();
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt)));
const accountIcon = resolveEffectiveAccountIcon(user);
const accountIcons = await ctx.users.listIcons(user.id);
const payload = {
+26 -2
View File
@@ -1,3 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
@@ -24,6 +25,7 @@ import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
@@ -65,7 +67,23 @@ export const createGatewayApiServer = async () => {
sessionTtlSeconds: config.sessionTtlSeconds,
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
});
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(redis.client),
config.redisKeyPrefix,
config.gameTokenSecret
);
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
try {
await flushPublisher.initialize();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation initialization failed.');
}
const kakaoClient = new KakaoOAuthClient({
restKey: config.kakaoRestKey,
adminKey: config.kakaoAdminKey,
@@ -89,9 +107,12 @@ export const createGatewayApiServer = async () => {
config.defaultNavigationConfigFile
);
const app = fastify({
logger: true,
logger: safeHttpLoggerOptions,
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
trustProxy: false,
routerOptions: gatewayFastifyRouterOptions,
});
app.setErrorHandler(safeHttpErrorHandler);
const webPush = new WebPushCoordinator(
postgres.prisma as GatewayPrismaClient,
{
@@ -104,8 +125,9 @@ export const createGatewayApiServer = async () => {
(error) => app.log.error({ err: error }, 'web push delivery failed')
);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, {
origin: true,
origin: config.corsAllowedOrigins,
credentials: true,
});
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
@@ -152,6 +174,8 @@ export const createGatewayApiServer = async () => {
releases,
orchestrator,
profileStatus,
authBudget,
requestIp: req.ip,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
+35 -3
View File
@@ -1,8 +1,40 @@
import { initTRPC } from '@trpc/server';
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
import { initTRPC, TRPCError } from '@trpc/server';
import type { GatewayApiContext } from './context.js';
const t = initTRPC.context<GatewayApiContext>().create();
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
export const router = t.router;
export const procedure = t.procedure;
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
if (!isBudgetedAuthAction(path)) return next();
const raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => {
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (
(path.startsWith('account.') || path === 'auth.issueGameSession') &&
typeof input.sessionToken === 'string' &&
input.sessionToken.length <= 256
) {
const session = await ctx.sessions.getSession(input.sessionToken);
if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`;
}
return undefined;
};
try {
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
} catch (error) {
if (!(error instanceof AuthBudgetError)) throw error;
throw new TRPCError({
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
message:
error.reason === 'limited'
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
});
}
});
+4 -4
View File
@@ -1,3 +1,4 @@
import { sendBoundedNotification } from './sendNotification.js';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
export interface WebPushCoordinatorConfig {
enabled: boolean;
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
try {
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
parsePushEndpoint(delivery.subscription.endpoint);
await webPush.sendNotification(
await sendBoundedNotification(
{
endpoint: delivery.subscription.endpoint,
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
body: delivery.notification.body,
url: delivery.notification.url,
tag: delivery.notification.tag,
}),
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
})
);
await this.prisma.$executeRaw(GatewayPrisma.sql`
UPDATE web_push_delivery
@@ -0,0 +1,99 @@
import { request, type RequestOptions } from 'node:https';
import type { ClientRequest, IncomingMessage } from 'node:http';
import webPush from 'web-push';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
export class PushTransportError extends Error {
constructor(
message: string,
readonly statusCode = 0
) {
super(message);
}
}
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
export const sendPushRequest = (
details: webPush.RequestDetails,
requestImpl: PushRequest = request,
timeoutMs = 10_000
): Promise<void> =>
new Promise((resolve, reject) => {
const endpoint = parsePushEndpoint(details.endpoint);
let response: IncomingMessage | undefined;
let req: ClientRequest | undefined;
let settled = false;
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) {
response?.destroy();
req?.destroy();
reject(error);
} else resolve();
};
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
try {
req = requestImpl(
{
protocol: 'https:',
hostname: endpoint.hostname,
port: 443,
path: `${endpoint.pathname}${endpoint.search}`,
method: details.method,
headers: details.headers,
agent: publicPushAgent,
},
(incoming) => {
response = incoming;
if (settled) {
incoming.destroy();
return;
}
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
finish(new PushTransportError('Push service response exceeds its size limit.'));
return;
}
let bytes = 0;
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_PUSH_RESPONSE_BYTES)
finish(new PushTransportError('Push service response exceeds its size limit.'));
});
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('end', () => {
const status = incoming.statusCode ?? 0;
finish(
status >= 200 && status < 300
? undefined
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
);
});
}
);
req.on('error', (error) =>
finish(
error instanceof PushEndpointPolicyError
? error
: new PushTransportError('Push service request failed.')
)
);
req.end(details.body ?? undefined);
} catch {
finish(new PushTransportError('Push service request failed.'));
}
});
export const sendBoundedNotification = async (
subscription: webPush.PushSubscription,
payload: string
): Promise<void> => {
parsePushEndpoint(subscription.endpoint);
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
await sendPushRequest(details);
};
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
const targetSession = await sessions.createSession(target);
const flushes: Array<{ userId: string; reason?: string }> = [];
const app = fastify({ logger: false });
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
app.addHook('onRequest', createApiOriginGuard(origins));
await app.register(cors, { origin: origins, credentials: true });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
@@ -176,6 +180,29 @@ const postTrpc = async (
};
describe('admin security over HTTP transport', () => {
it('rejects an untrusted browser origin before a valid session can log out', async () => {
const harness = await createHarness();
for (const origin of ['https://untrusted.example', 'null']) {
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
}
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin: 'https://sam.hided.net',
'access-control-request-method': 'POST',
'access-control-request-headers': 'x-session-token,content-type',
},
});
expect(allowed.status).toBe(204);
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
});
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
const harness = await createHarness(['admin.profiles.runtime:che:default']);
const input = { profileName: 'che:default' };
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-test:${randomUUID()}`;
const store = new RedisAuthCounterStore(client);
const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
await client.del(keys);
await client.quit();
});
it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => {
const attempts = await Promise.all(
Array.from({ length: 100 }, () =>
store.consume([
{ key: keys[0]!, limit: 10, windowMs: 60_000 },
{ key: keys[1]!, limit: 1000, windowMs: 60_000 },
])
)
);
expect(attempts.filter((retry) => retry === 0)).toHaveLength(10);
expect(attempts.filter((retry) => retry > 0)).toHaveLength(90);
expect(await client.get(keys[0]!)).toBe('10');
expect(await client.get(keys[1]!)).toBe('10');
expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0);
});
it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => {
const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }];
expect(await store.consume(counters)).toBe(0);
const before = await client.pTTL(keys[2]!);
expect(await store.consume(counters)).toBeGreaterThan(0);
expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before);
await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0);
expect(await store.consume(counters)).toBe(0);
});
it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => {
await client.set(keys[3]!, '10');
await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({
reason: 'unavailable',
});
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
describe('authentication attempt and resource budgets', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shares a login subject across clients without exposing account or address in keys', async () => {
const store = new InMemoryAuthCounterStore();
const consume = vi.spyOn(store, 'consume');
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
for (let i = 0; i < 10; i++)
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
const work = vi.fn(async () => 'must not run');
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
reason: 'limited',
retryAfterSeconds: 60,
});
expect(work).not.toHaveBeenCalled();
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
'ok'
);
});
it('does not extend a blocked account window and admits it after expiry', async () => {
vi.useFakeTimers();
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
await vi.advanceTimersByTimeAsync(59_000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
retryAfterSeconds: 1,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
'recovered'
);
});
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
});
it('bounds expensive work and releases capacity after resolver failures', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const work = vi.fn(async () => {
await pending;
throw new Error('fixture resolver failure');
});
const running = Array.from({ length: 4 }, () =>
budget.run('auth.login', 'client', undefined, work).catch(() => null)
);
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
const rejected = vi.fn(async () => null);
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
AuthBudgetError
);
expect(rejected).not.toHaveBeenCalled();
release();
await Promise.all(running);
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
});
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
vi.useFakeTimers();
let release!: (retry: number) => void;
const waiting = new Promise<number>((resolve) => {
release = resolve;
});
const store = { consume: vi.fn(() => waiting) };
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
const work = vi.fn(async () => 'ok');
const requests = Array.from({ length: 32 }, () =>
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
);
await vi.advanceTimersByTimeAsync(2001);
expect(
(await Promise.all(requests)).every(
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
)
).toBe(true);
const actorLookup = vi.fn(async () => undefined);
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
reason: 'unavailable',
});
expect(actorLookup).not.toHaveBeenCalled();
expect(work).not.toHaveBeenCalled();
expect(store.consume).toHaveBeenCalledTimes(32);
release(0);
await Promise.resolve();
await Promise.resolve();
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
expect(work).toHaveBeenCalledTimes(1);
});
it('fails closed before credential work when the counter backend errors or stalls', async () => {
const work = vi.fn(async () => null);
const failing = new AuthAttemptBudget(
{
consume: async () => {
throw new Error('private-backend-error');
},
},
'fixture',
'secret'
);
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
reason: 'unavailable',
});
vi.useFakeTimers();
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(2001);
expect(await result).toMatchObject({ reason: 'unavailable' });
expect(work).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,238 @@
import { constants, publicEncrypt, randomUUID } from 'node:crypto';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { createGatewayApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-http-test:${randomUUID()}`;
const envelope = createPasswordEnvelopeService();
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
const keys: string[] = [];
for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page);
if (keys.length) await client.del(keys);
await client.quit();
});
const credential = (password: string) => {
const key = envelope.getPublicKey();
return {
keyId: key.keyId,
ciphertext: publicEncrypt(
{ key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(password)
).toString('base64'),
};
};
const fixture = async (available = true) => {
const users = createInMemoryUserRepository();
await users.createUser({ username: 'target-user', password: 'correct-password' });
await users.createUser({ username: 'other-user', password: 'correct-password' });
const requestIps = new Set<string>();
const peer = (request: FastifyRequest): string => {
requestIps.add(request.ip);
return request.ip;
};
const verify = vi.spyOn(users, 'verifyPassword');
const open = vi.fn(envelope.open);
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 });
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(available ? client : client.duplicate()),
`${prefix}:${randomUUID()}`,
'synthetic-budget-key'
);
const app = fastify({ trustProxy: false });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({
users,
sessions,
passwordEnvelope: { ...envelope, open },
authBudget,
requestIp: peer(req),
requestHeaders: req.headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'synthetic-game-key',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: true,
localRegistrationEnabled: true,
localAccountGraceDays: 7,
profiles: {} as never,
orchestrator: {} as never,
profileStatus: {} as never,
prisma: {} as never,
}),
},
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (operation: string, input: unknown, headers: Record<string, string> = {}) =>
fetch(`${address}/trpc/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(input),
});
return { app, address, post, users, sessions, verify, open, requestIps };
};
it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => {
const f = await fixture();
try {
for (let i = 0; i < 10; i++) {
const response = await f.post(
'auth.login',
{ username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') },
{ 'x-forwarded-for': `198.51.100.${i + 1}` }
);
expect(response.status).toBe(401);
}
expect(f.requestIps.size).toBe(1);
expect(f.requestIps.has('127.0.0.1')).toBe(true);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
const denied = await f.post(
'auth.login',
{ username: 'target-user', credential: credential('correct-password') },
{ 'x-forwarded-for': '203.0.113.99' }
);
expect(denied.status).toBe(429);
expect(JSON.stringify(await denied.json())).not.toContain('stack');
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect(
(await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
await f.app.close();
}
});
it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => {
const f = await fixture();
try {
const statuses: number[] = [];
for (let round = 0; round < 4; round++) {
const input = Object.fromEntries(
Array.from({ length: 3 }, (_value, i) => [
String(i),
{ username: 'target-user', credential: credential('wrong-password') },
])
);
const response = await f.post('auth.login,auth.login,auth.login?batch=1', input);
const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>;
statuses.push(...body.map((entry) => entry.error.data.httpStatus));
}
expect(statuses.filter((status) => status === 401)).toHaveLength(10);
expect(statuses.filter((status) => status === 429)).toHaveLength(2);
expect(f.verify).toHaveBeenCalledTimes(10);
} finally {
await f.app.close();
}
});
it('shares the password/deletion subject across sessions of the same authenticated actor', async () => {
const f = await fixture();
const user = await f.users.findByUsername('target-user');
try {
for (let i = 0; i < 10; i++) {
const session = await f.sessions.createSession(user!);
const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('wrong-password'),
newCredential: credential('new-password'),
});
expect(response.status).toBe(401);
}
const session = await f.sessions.createSession(user!);
expect(
(
await f.post('account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('correct-password'),
})
).status
).toBe(429);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined();
} finally {
await f.app.close();
}
});
it('fails closed on unavailable protection while keeping logout independent of its counters', async () => {
const f = await fixture(false);
try {
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(503);
expect(f.open).not.toHaveBeenCalled();
expect(f.verify).not.toHaveBeenCalled();
const user = await f.users.findByUsername('target-user');
const session = await f.sessions.createSession(user!);
expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200);
expect(await f.sessions.getSession(session.sessionToken)).toBeNull();
expect(
(await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status
).toBe(503);
const malformed = await fetch(`${f.address}/trpc/auth.login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{',
});
expect(malformed.status).toBe(400);
} finally {
await f.app.close();
}
});
it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => {
const f = await fixture();
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
f.verify.mockImplementation(async () => {
await waiting;
return false;
});
const input = { username: 'target-user', credential: credential('wrong-password') };
const pending = Array.from({ length: 4 }, () => f.post('auth.login', input));
try {
await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4));
expect((await f.post('auth.login', input)).status).toBe(429);
expect(f.open).toHaveBeenCalledTimes(4);
release();
expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]);
f.verify.mockRestore();
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
release();
await Promise.allSettled(pending);
await f.app.close();
}
});
});
+93
View File
@@ -1242,6 +1242,59 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' });
const parent = await sessions.createSession(user);
const issuedAt = new Date().toISOString();
vi.useFakeTimers({ now: new Date(issuedAt) });
const original = users.findById.bind(users);
const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => {
const snapshot = await original(id);
vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot.
return snapshot;
});
try {
const result = await caller.auth.issueGameSession({
sessionToken: parent.sessionToken,
profile: 'che:default',
});
expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt);
} finally {
find.mockRestore();
vi.useRealTimers();
}
});
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
const parent = await sessions.createSession(user);
const issuedAt = new Date().toISOString();
vi.useFakeTimers({ now: new Date(issuedAt) });
const create = vi.spyOn(sessions, 'createGameSession');
const original = sessions.createGameSession.bind(sessions);
create.mockImplementationOnce(async (...args) => {
// Snapshot admitted before the subsequent logout/flush instant.
const game = await original(...args);
vi.advanceTimersByTime(5_000);
await sessions.revokeSession(parent.sessionToken);
return game;
});
try {
const issued = await caller.auth.issueGameSession({
sessionToken: parent.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.issuedAt).toBe(issuedAt);
expect(payload?.expiresAt).toBe(new Date(Date.parse(issuedAt) + 600_000).toISOString());
} finally {
create.mockRestore();
vi.useRealTimers();
}
});
it('keeps the migrated member number inside the encrypted game identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
@@ -1474,6 +1527,46 @@ describe('account self service', () => {
}
});
it('bounds pending icon uploads without changing the owned library limit', async () => {
const { caller, users, sessions, userIconUpload } = buildCaller();
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
const session = await sessions.createSession(user);
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const input = {
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
};
const pending: Array<() => void> = [];
userIconUpload.upload.mockImplementation(
({ filename }) =>
new Promise((resolve) => {
pending.push(() =>
resolve({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
})
);
})
);
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
try {
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
expect(await users.listIcons(user.id)).toHaveLength(4);
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
}));
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
expect(await users.listIcons(user.id)).toHaveLength(5);
});
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
@@ -0,0 +1,79 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
} from '@sammo-ts/common/auth/sessionRevocation';
import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js';
const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
describe.skipIf(!url)('persistent Gateway flush over real Redis', () => {
const client = createClient({ url });
const channel = `revocation-publisher-test:${randomUUID()}:flush`;
const publisher = new RedisGatewayFlushPublisher(client, channel);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 }))
if (keys.length) await client.del(keys);
await client.quit();
});
it('initializes once and preserves the original cutoff across publisher restarts', async () => {
await publisher.initialize();
const baseline = await client.get(buildSessionRevocationBaselineKey(channel));
expect(Number(baseline)).toBeGreaterThan(0);
await new RedisGatewayFlushPublisher(client, channel).initialize();
expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline);
expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1);
});
it('persists a revocation when there are zero subscribers', async () => {
const userId = randomUUID();
await publisher.publishUserFlush(userId, 'logout');
expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0);
expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1);
});
it('makes the watermark readable before delivering flush metadata', async () => {
const subscriber = client.duplicate();
await subscriber.connect();
const userId = randomUUID();
let delivered: { event: Record<string, unknown>; persisted: string | null } | undefined;
await subscriber.subscribe(channel, async (raw) => {
delivered = {
event: JSON.parse(raw) as Record<string, unknown>,
persisted: await client.get(buildUserSessionRevocationKey(channel, userId)),
};
});
try {
await publisher.publishUserFlush(userId, 'identity-updated', {
displayName: 'fixture',
identityRevision: 'revision-1',
});
await vi.waitFor(() => expect(delivered).toBeDefined());
expect(delivered!.event).toMatchObject({
userId,
reason: 'identity-updated',
displayName: 'fixture',
identityRevision: 'revision-1',
});
expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string));
} finally {
await subscriber.unsubscribe(channel);
await subscriber.quit();
}
});
it('never moves a newer watermark backwards under late or concurrent flushes', async () => {
const userId = randomUUID();
const future = String(Date.now() + 60_000);
await client.set(buildUserSessionRevocationKey(channel, userId), future);
await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent')));
expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future);
});
it('fails closed for an unavailable client instead of publishing without storage', async () => {
const offline = client.duplicate();
const closed = new RedisGatewayFlushPublisher(offline, channel);
await expect(closed.initialize()).rejects.toThrow('unavailable');
await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable');
});
});
@@ -0,0 +1,238 @@
import { randomUUID } from 'node:crypto';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { appRouter } from '../src/router.js';
import { createGatewayApiContext } from '../src/context.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { RedisGatewaySessionService } from '../src/auth/redisSessionService.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
const redisUrl = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
const limit = 256;
const user = async () => {
const users = createInMemoryUserRepository();
return users.createUser({ username: `session-${randomUUID()}`, password: 'synthetic-test-password' });
};
describe.skipIf(!redisUrl)('game session lifecycle over real Redis', () => {
const client = createClient({ url: redisUrl });
const prefix = `game-session-security-test:${randomUUID()}`;
const store = new RedisGatewaySessionService(client, {
keyPrefix: prefix,
sessionTtlSeconds: 600,
gameSessionTtlSeconds: 300,
});
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
for await (const keys of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) {
if (keys.length) await client.del(keys);
}
await client.quit();
});
const keys = (token: string) => ({
parent: `${prefix}:session:${token}`,
games: `${prefix}:session-games:${token}`,
});
const httpFixture = async () => {
const users = createInMemoryUserRepository();
const first = await users.createUser({ username: `first-${randomUUID()}`, password: 'synthetic-password' });
const second = await users.createUser({ username: `second-${randomUUID()}`, password: 'synthetic-password' });
const parents = await Promise.all([
store.createSession(first),
store.createSession(first),
store.createSession(second),
]);
const profiles = { getProfile: vi.fn(async () => null) };
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(client),
`${prefix}:${randomUUID()}`,
'synthetic-rate-key'
);
const passwordEnvelope = createPasswordEnvelopeService();
const app = fastify({ trustProxy: false });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({
users,
sessions: store,
passwordEnvelope,
authBudget,
requestIp: req.ip,
requestHeaders: req.headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'synthetic-game-key',
gameSessionTtlSeconds: 300,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: true,
localRegistrationEnabled: true,
localAccountGraceDays: 7,
profiles: profiles as never,
orchestrator: {} as never,
profileStatus: {} as never,
prisma: {} as never,
}),
},
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (operation: string, input: unknown) =>
fetch(`${address}/trpc/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const issue = (index: number) =>
post('auth.issueGameSession', { sessionToken: parents[index]!.sessionToken, profile: 'che:default' });
return { app, post, issue, parents, profiles };
};
it('limits real HTTP issuance by account across login sessions before profile/icon reads', async () => {
const f = await httpFixture();
try {
for (let i = 0; i < 32; i++) expect((await f.issue(i % 2)).status).toBe(200);
const denied = await f.issue(1);
expect(denied.status).toBe(429);
expect(f.profiles.getProfile).toHaveBeenCalledTimes(32);
expect(JSON.stringify(await denied.json())).not.toContain('stack');
expect((await f.issue(2)).status).toBe(200);
expect(
(await f.post('auth.issueGameSession', { sessionToken: 'unknown', profile: 'che:default' })).status
).toBe(401);
} finally {
await f.app.close();
}
});
it('maps the live record cap to HTTP 429 without invalidating existing tokens', async () => {
const f = await httpFixture();
const parent = f.parents[0]!;
try {
const first = await store.createGameSession(parent.sessionToken, 'che:default');
for (let i = 1; i < limit; i++) await store.createGameSession(parent.sessionToken, 'che:default');
expect((await f.issue(0)).status).toBe(429);
expect(await store.getGameSession('che:default', first!.gameToken)).toEqual(first);
expect((await f.post('auth.logout', { sessionToken: parent.sessionToken })).status).toBe(200);
expect((await f.issue(0)).status).toBe(401);
expect(await client.sCard(keys(parent.sessionToken).games)).toBe(0);
} finally {
await f.app.close();
}
});
it('preserves game records when explicitly revoking only the parent', async () => {
const parent = await store.createSession(await user());
const game = await store.createGameSession(parent.sessionToken, 'che:default');
await store.revokeSession(parent.sessionToken, { revokeGames: false });
expect(await store.getSession(parent.sessionToken)).toBeNull();
expect(await store.getGameSession('che:default', game!.gameToken)).toEqual(game);
});
it('atomically admits exactly 256 live records across concurrent profile requests', async () => {
const session = await store.createSession(await user());
const outcomes = await Promise.allSettled(
Array.from({ length: limit + 32 }, (_, i) =>
store.createGameSession(session.sessionToken, i % 2 ? 'che:default' : 'hwe:default')
)
);
expect(outcomes.filter((x) => x.status === 'fulfilled' && x.value)).toHaveLength(limit);
expect(outcomes.filter((x) => x.status === 'rejected')).toHaveLength(32);
expect(await client.sCard(keys(session.sessionToken).games)).toBe(limit);
});
it('removes expired membership before admitting a new game session', async () => {
const session = await store.createSession(await user());
const key = keys(session.sessionToken).games;
const expiredKeys = Array.from({ length: limit }, (_, i) => `${prefix}:game-session:che:default:expired-${i}`);
await client.sAdd(key, expiredKeys);
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
expect(await client.sCard(key)).toBe(1);
});
it('does not revive a parent revoked after its snapshot was read', async () => {
const session = await store.createSession(await user());
const get = vi.spyOn(store, 'getSession');
get.mockImplementationOnce(async () => {
await store.revokeSession(session.sessionToken);
return session;
});
try {
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
expect(await client.exists(keys(session.sessionToken).games)).toBe(0);
} finally {
get.mockRestore();
}
});
it('preserves independent game TTL without resetting the index to a fresh parent TTL', async () => {
const session = await store.createSession(await user());
const { parent, games } = keys(session.sessionToken);
await client.pExpire(parent, 1000);
const game = await store.createGameSession(session.sessionToken, 'che:default');
expect(game).not.toBeNull();
expect(await client.pTTL(games)).toBeGreaterThan(295_000);
expect(await client.pTTL(games)).toBeLessThanOrEqual(300_000);
});
it('revokes existing oversized indexes in bounded batches while preserving other parents', async () => {
const session = await store.createSession(await user());
const other = await store.createSession(await user());
const gameKeys = Array.from({ length: limit + 9 }, (_, i) => `${prefix}:game-session:che:default:legacy-${i}`);
const transaction = client.multi();
for (const key of gameKeys) transaction.set(key, '{}', { EX: 300 });
transaction.sAdd(keys(session.sessionToken).games, gameKeys);
await transaction.exec();
await expect(store.createGameSession(session.sessionToken, 'che:default')).rejects.toThrow(
'Game session limit'
);
await store.revokeSession(session.sessionToken);
expect(
await client.exists([keys(session.sessionToken).parent, keys(session.sessionToken).games, ...gameKeys])
).toBe(0);
expect(await store.getSession(other.sessionToken)).not.toBeNull();
});
});
describe('in-memory game session lifecycle', () => {
it('enforces the same live cap and reclaims expired records', async () => {
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 1 });
const session = await store.createSession(await user());
try {
vi.useFakeTimers();
for (let i = 0; i < limit; i++)
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
await expect(store.createGameSession(session.sessionToken, 'hwe:default')).rejects.toThrow(
'Game session limit'
);
vi.advanceTimersByTime(1001);
expect(await store.createGameSession(session.sessionToken, 'hwe:default')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
it('does not issue after a concurrent revoke', async () => {
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 300 });
const session = await store.createSession(await user());
const get = vi.spyOn(store, 'getSession').mockImplementationOnce(async () => {
await store.revokeSession(session.sessionToken);
return session;
});
try {
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
} finally {
get.mockRestore();
}
});
});
@@ -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 gatewayRouter, procedure as gatewayProcedure } 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: '/gateway',
trpcOptions: {
router: gatewayRouter({
internal: gatewayProcedure.query(internalFailure),
invalid: gatewayProcedure.query(badInput),
}),
createContext: () => ({}),
},
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
for (const prefix of ['gateway']) {
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();
}
});
});
+11 -8
View File
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
await expect(createClient().signup('access-token')).resolves.toEqual({
alreadyRegistered: true,
});
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
headers: {
Authorization: 'Bearer access-token',
},
});
expect(fetchMock).toHaveBeenCalledWith(
new URL('https://kapi.example.test/v1/user/signup'),
expect.objectContaining({
headers: {
Authorization: 'Bearer access-token',
},
redirect: 'error',
signal: expect.any(AbortSignal),
})
);
});
it('continues to reject unrelated Kakao signup errors', async () => {
@@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => {
})
);
await expect(createClient().signup('access-token')).rejects.toThrow(
'Kakao signup error: {"msg":"invalid request","code":-201}'
);
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
});
});
@@ -0,0 +1,95 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it } from 'vitest';
import { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
describe('bounded Kakao provider transport', () => {
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => {
const server = createServer((_request, response) => respond(response));
servers.push(server);
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('Fixture provider listener failed.');
const origin = `http://127.0.0.1:${address.port}`;
return new KakaoOAuthClient({
restKey: 'synthetic-key',
redirectUri: `${origin}/callback`,
apiHost: origin,
oauthHost: origin,
requestTimeoutMs,
});
};
it('retains successful token/account responses and documented recovery responses', async () => {
const client = await provider((res) =>
res.end(
JSON.stringify({
id: 42,
access_token: 'synthetic-token',
expires_in: 3600,
kakao_account: {
has_email: true,
email: 'fixture@example.test',
is_email_valid: true,
is_email_verified: true,
},
})
)
);
expect(await client.exchangeCode('synthetic-code')).toMatchObject({
accessToken: 'synthetic-token',
accessTokenExpiresIn: 3600,
});
expect(await client.getMe('synthetic-token')).toMatchObject({
id: '42',
kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true },
});
});
it('aborts both header and response-body stalls within the configured deadline', async () => {
const silent = await provider(() => {}, 100);
await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const partial = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{');
}, 100);
await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => {
const oversized = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' });
res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) })));
});
await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data'));
await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('does not follow redirects or expose provider error details', async () => {
let destinationRequests = 0;
const destination = await provider((res) => {
destinationRequests += 1;
res.end('{}');
});
const destinationUrl = new URL(destination.buildAuthUrl('state', []));
const redirect = await provider((res) => {
res.writeHead(302, { location: destinationUrl.origin });
res.end();
});
await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
expect(destinationRequests).toBe(0);
const failure = await provider((res) => {
res.writeHead(400);
res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 }));
});
await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.');
});
});
+17 -2
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RemoteBuildRunner,
@@ -34,12 +37,24 @@ describe('sanitizeReleaseBuildEnv', () => {
});
describe('RemoteBuildRunner', () => {
let directory: string;
let tokenFile: string;
beforeAll(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builder-token-test-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterAll(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('streams progress and returns the builder result without sending secrets', async () => {
const messages = [
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
{ result: { ok: true, exitCode: 0, output: 'building' } },
];
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
expect(init?.redirect).toBe('error');
const request = JSON.parse(String(init?.body)) as {
commands: Array<{ env: Record<string, string> }>;
};
@@ -49,7 +64,7 @@ describe('RemoteBuildRunner', () => {
});
}) as unknown as typeof fetch;
const progress: BuildProgressEvent[] = [];
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl, { tokenFile }).run(
[
{
command: 'pnpm',
@@ -0,0 +1,114 @@
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RemoteBuildRunner } from '../src/orchestrator/buildRunner.js';
describe('release builder authenticated HTTP and bounded responses', () => {
let directory: string;
let tokenFile: string;
const servers: Server[] = [];
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-build-http-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await fs.rm(directory, { recursive: true, force: true });
});
const provider = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
const server = createServer(handler);
servers.push(server);
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('Fixture listener unavailable.');
return `http://127.0.0.1:${address.port}`;
};
const commands = [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/srv/core/repository' }];
it('sends the file token only as a header and preserves successful stream results', async () => {
let received = '';
const endpoint = await provider((req, res) => {
expect(req.headers['x-release-builder-token']).toBe('t'.repeat(64));
req.on('data', (chunk: Buffer) => {
received += chunk.toString();
});
req.on('end', () => res.end(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: 'done' } })}\n`));
});
expect(await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).toMatchObject({
ok: true,
output: 'done',
});
expect(received).not.toContain('t'.repeat(64));
});
it('fails closed without a valid credential before sending a request', async () => {
let requests = 0;
const endpoint = await provider((_req, res) => {
requests += 1;
res.end();
});
const result = await new RemoteBuildRunner(endpoint).run(commands);
expect(result.ok).toBe(false);
expect(requests).toBe(0);
expect(result.output).not.toContain(directory);
});
it('rejects malformed and oversized progress without exposing upstream error payloads', async () => {
for (const body of [
'{"event":{"type":"OUTPUT","stream":"stdout","message":42}}\n',
'x'.repeat(512 * 1024 + 1),
'null\n',
'{"result":{"ok":true,"exitCode":2,"output":""}}\n',
]) {
const endpoint = await provider((_req, res) => res.end(body));
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
}
const endpoint = await provider((_req, res) => {
res.writeHead(500);
res.end('synthetic-sensitive-detail'.repeat(100_000));
});
const result = await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands);
expect(result.output).toBe('Release builder returned HTTP 500.');
const rejected = await provider((_req, res) =>
res.end(JSON.stringify({ error: 'synthetic-sensitive-detail' }) + '\n')
);
expect((await new RemoteBuildRunner(rejected, fetch, { tokenFile }).run(commands)).output).toBe(
'Release builder rejected the build request.'
);
});
it('does not forward the token to redirect destinations', async () => {
let requests = 0;
const destination = await provider((_req, res) => {
requests += 1;
res.end();
});
const endpoint = await provider((_req, res) => {
res.writeHead(307, { location: destination });
res.end();
});
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
expect(requests).toBe(0);
});
it('bounds header/body stalls and preserves explicit operator cancellation', async () => {
const silent = await provider(() => {});
expect((await new RemoteBuildRunner(silent, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const partial = await provider((_req, res) => {
res.writeHead(200);
res.write('{');
});
expect((await new RemoteBuildRunner(partial, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const controller = new AbortController();
const pending = new RemoteBuildRunner(partial, fetch, { tokenFile }).run(commands, undefined, {
signal: controller.signal,
});
controller.abort();
expect(await pending).toMatchObject({ ok: false, aborted: true, output: 'Build cancelled by operator.' });
});
});
@@ -40,9 +40,7 @@ describe('remote user icon store', () => {
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const digest = createHash('sha256').update(body).digest('hex');
const expected = createHmac('sha256', secret)
.update(
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
)
.update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`)
.digest('hex');
expect(headers['x-image-signature']).toBe(expected);
});
@@ -58,4 +56,15 @@ describe('remote user icon store', () => {
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
).rejects.toThrow('HTTP 401');
});
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteUserIconStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `icons/users/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
});
@@ -1,3 +1,4 @@
import * as pushSender from '../src/webPush/sendNotification.js';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import webPush from 'web-push';
@@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => {
notificationId: notification.id,
},
});
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 });
const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 });
try {
coordinator.start();
await vi.waitFor(
@@ -0,0 +1,98 @@
import { Agent, request as httpsRequest } from 'node:https';
import { createPublicPushLookup, PushEndpointPolicyError } from '../src/webPush/endpointPolicy.js';
import { createServer, request, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { sendPushRequest } from '../src/webPush/sendNotification.js';
const details = {
endpoint: 'https://push.example/subscription',
method: 'POST' as const,
headers: {},
body: Buffer.from('encrypted'),
};
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return (server.address() as { port: number }).port;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
const transport =
(port: number): Parameters<typeof sendPushRequest>[1] =>
(options, callback) =>
request({ ...options, protocol: 'http:', hostname: '127.0.0.1', port, agent: undefined }, callback);
describe('bounded WebPush response transport', () => {
it('accepts normal success and preserves terminal HTTP status without retaining sensitive response bodies', async () => {
let status = 201;
const server = createServer((_, res) => {
res.writeHead(status);
res.end('private upstream details');
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).resolves.toBeUndefined();
status = 410;
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({
statusCode: 410,
message: 'Push service returned HTTP 410.',
});
} finally {
await close(server);
}
});
it.each([true, false])('rejects oversized response with declared length %s', async (declared) => {
const server = createServer((_, res) => {
if (declared) res.setHeader('content-length', 20_000);
res.end(Buffer.alloc(20_000));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toThrow('size limit');
} finally {
await close(server);
}
});
it('ends a continuously dripping response on the whole exchange deadline', async () => {
const server = createServer((_, res) => {
const timer = setInterval(() => res.write('x'), 10);
res.on('close', () => clearInterval(timer));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port), 100)).rejects.toThrow('timed out');
} finally {
await close(server);
}
});
it('preserves terminal public-address policy failures from actual socket lookup', async () => {
const agent = new Agent({ lookup: createPublicPushLookup(async () => [{ address: '127.0.0.1', family: 4 }]) });
try {
await expect(
sendPushRequest(details, (options, callback) => httpsRequest({ ...options, agent }, callback))
).rejects.toBeInstanceOf(PushEndpointPolicyError);
} finally {
agent.destroy();
}
});
it('does not follow redirects', async () => {
let calls = 0;
const server = createServer((_, res) => {
calls++;
res.writeHead(307, { location: 'https://elsewhere.example' });
res.end();
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({ statusCode: 307 });
expect(calls).toBe(1);
} finally {
await close(server);
}
});
});
+6 -1
View File
@@ -29,7 +29,12 @@ const main = async (): Promise<void> => {
baseEnv: config.baseEnv,
});
const migrationRunner = new PnpmBuildRunner();
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner);
const releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetch,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(
repository,
@@ -160,7 +160,12 @@ export class GatewayReleaseController {
private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch
) {
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner, fetchImpl);
this.releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetchImpl,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
}
@@ -509,7 +514,11 @@ export class GatewayReleaseController {
}
private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
await this.appendLog(
operationId,
'readiness',
'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'
);
const deadline = performance.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl =
@@ -524,10 +533,7 @@ export class GatewayReleaseController {
);
while (performance.now() < deadline) {
try {
const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl),
this.fetchImpl(frontendUrl),
]);
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
const processes = await this.processManager.list();
const expected = processes.filter((process) => expectedNames.includes(process.name));
const safe = expected.filter(
@@ -447,6 +447,8 @@ describe('GatewayReleaseController', () => {
it('runs release builds remotely while keeping Gateway migration on the runtime runner', async () => {
const workspace = await createReleaseWorkspace();
const tokenFile = path.join(workspace, 'builder-token.fixture');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
const harness = createRepository();
const localCommandGroups: string[][] = [];
const remoteRequests: Array<{ commands: Array<{ args: string[] }> }> = [];
@@ -481,10 +483,15 @@ describe('GatewayReleaseController', () => {
running.delete(name);
},
},
{ ...config, releaseBuilderUrl: 'http://builder:15100' },
{
...config,
releaseBuilderUrl: 'http://builder:15100',
baseEnv: { ...config.baseEnv, RELEASE_BUILDER_TOKEN_FILE: tokenFile },
},
() => new Date('2026-08-01T00:00:00.000Z'),
async (input, init) => {
if (String(input) === 'http://builder:15100/v1/builds') {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
remoteRequests.push(JSON.parse(String(init?.body)) as { commands: Array<{ args: string[] }> });
return new Response(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: '' } })}\n`, {
status: 200,
+20 -2
View File
@@ -4,17 +4,18 @@
| 환경 | 공개 주소·prefix | 접속·연결 계약 |
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. |
| Ref | `dev-sam-ref.hided.net` | 공개 서비스 종료(410). 작업공간의 loopback 비교 Ref와 별개입니다. |
| 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. |
| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. |
| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. |
| sam PHP 게임 | `sam.hided.net/sam/` | 2026-10-10 종료(410). `sam_hided_net`의 게시판·위키·DB는 유지합니다. |
`dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수
있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된
배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로
사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는
`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다.
현재 기기가 운영 host `serv-2204`이면 SSH 없이 해당 stack 경로에서 작업합니다.
외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
@@ -81,6 +82,23 @@ GAME_TRPC_PATH=/hwe/api/trpc
GAME_API_EVENTS_PATH=/hwe/api/events
```
## API의 browser Origin 경계
Gateway는 `GATEWAY_PUBLIC_URL`, game API는 `GATEWAY_PUBLIC_URL`과
`GAME_PUBLIC_URL`의 origin을 기본으로 허용합니다. 공개 URL의 `/gateway/`, `/che/`
같은 path는 origin에 포함하지 않습니다. 추가 frontend host가 필요하면 runtime의
`API_ALLOWED_ORIGINS`에 정확한 origin을 쉼표로 지정합니다. 예:
`http://localhost:14997,http://127.0.0.1:14997`. scheme·host·port 모두 일치해야 하며
wildcard·`null`·URL credential·path가 있는 추가 항목은 거부합니다.
허용하지 않은 `Origin`은 body parsing과 mutation 이전에 403으로 거부합니다.
허용 목록이 비어 있으면 Origin이 있는 요청은 모두 거부하므로 standalone game
개발 서버도 공개 URL 또는 명시 목록을 설정해야 합니다. Origin 없는 내부 client는
기존 session/Bearer/HMAC 인증을 통과해야 합니다. Origin 허용은 권한을 부여하지
않으며 비브라우저 client가 header를 조작하는 공격은 인증·resource ACL이 막습니다.
PM2 managed API는 runtime base 환경을 상속하므로 설정 변경 뒤 해당 API process의
환경까지 갱신합니다. 공개 운영·개발 목록을 서로 복사하지 않습니다.
## Frontend build
Gateway:
+232
View File
@@ -546,3 +546,235 @@ schema head·component 검사는 그대로 수행합니다.
Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부
Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다. 운영 배포에서는
위 확인 목록을 실제 서비스 경로에서 다시 수행해 주세요.
## SSE 인증 전송과 API 진단 보안 (2026-10-09)
게임 frontend의 SSE는 `eventsource@5.1.2`의 fetch adapter로
`Authorization: Bearer`를 전달한다. URL에는 `scope=tournament` 같은 구독 선택만
남기며 token, URL credential, ambient cookie, redirect 전달을 허용하지 않는다.
기존 native EventSource를 사용하는 열린 탭은 새 game API에서 401을 받으므로
배포 후 새로고침해야 한다. 독립 배포라면 새 frontend를 먼저 게시하고 game API를
갱신한다. 새 frontend의 헤더 인증은 이전 game API도 지원한다. API만 먼저
갱신하면 구형 탭의 실시간 갱신이 중단될 수 있다.
SSE는 최초 요청과 매 frame 작업 전·전송 전에 Redis token, flush watermark,
user/session/profile과 게임 접근 제재를 확인한다. flush watermark는 아래 지속 회수
계약에 따라 Redis에서 읽으며 메모리에만 의존하지 않는다. 만료 타이머와 Gateway flush가
대기 중인 연결을 닫고 Redis 장애는 fail closed로 처리한다. 연결별 대기 작업은
32개, frame과 socket buffer는 64 KiB로 제한하고 slow consumer를 닫는다.
재연결 뒤 메인은 기존 갱신 큐로 snapshot을 복구한다. 게임 계산·RNG·DB schema와
read-model invalidation/grant의 사용자 범위는 변경하지 않는다. 전체 연결 admission은
아래 별도 계약을 따르며, 일반 게임 HTTP 인증의 전역 rate limit 증거로 확대하지 않는다.
### SSE 연결과 준비 작업 상한 (2026-10-09)
game API의 `RealtimeConnectionAdmission`은 프로세스별로 다음 용량을 소유한다.
| 범위 | 상한 |
| --------------------------------------------------------------- | ----- |
| 같은 인증 사용자: token·session·scope를 바꾸어도 공유 | 8개 |
| 인증 중/준비 중, 열린 연결, 닫힌 연결의 미완료 작업을 합친 전체 | 512개 |
| 인증·viewer 조회가 아직 끝나지 않은 연결 준비 | 32개 |
인증 actor는 검증한 token의 user ID로 결정하며 client query/user ID를 신뢰하지 않는다.
전체/준비 용량은 첫 async 인증 조회 전에 예약하고 사용자 용량은 viewer DB 조회 전에
예약한다. 대기 큐를 만들지 않고 사용자 초과는 429 `realtime_connection_limit`,
전체/준비 초과는 503 `realtime_unavailable`과 `Retry-After: 15`를 반환한다.
이미 열린 정상 연결은 새 요청의 초과 때문에 닫지 않는다.
연결 준비는 5초 뒤 503으로 실패한다. 호출자 timeout/client disconnect가 실제 Redis/SQL
완료를 뜻하지 않으므로 해당 준비 용량은 작업이 settle할 때까지 유지한다. 늦게 완료한
작업이 이미 끝난 HTTP 요청에서 SSE를 새로 열지 못한다. 열린 socket의 종료도 구독과
heartbeat를 즉시 정리하지만 미완료 frame 작업이 끝날 때까지 사용자/전체 용량을
보유한다. app shutdown은 새 admission과 늦은 준비를 거절하고 기존 stream을 닫는다.
이는 한 game API process/profile의 자원 상한이며 DB pool connection 수를 사용자 수로
환산한 값이나 다중 replica/모든 profile에 걸친 전역 제한이 아니다. 일반 request RPS와
backend의 모든 장애 복구를 보장하지 않는다. 기존 header auth, 만료/회수·개인정보
필터와 refresh grant는 유지한다. 비활성화 env key와 credential은 추가하지 않는다.
Game/Gateway HTTP logger는 raw URL/query/header/body와 error message/cause/stack
대신 static route, request ID, status와 허용된 error code만 남긴다. Fastify의
기본 404/오류 message도 정화한다. 공개 tRPC 오류의 stack을 제거하고 내부 500의
message는 일반 문구로 치환한다. 4xx의 사용자용 검증/권한 문구는 유지한다.
이 정책은 HTTP API 로그의 범위이며 release builder의 관리자 build log를
일괄 숨기지 않는다. 장애 조사 시 request ID·route·status·code와 별도 격리 재현을
사용하며 운영 HTTP 로그에 credential이나 raw error payload를 다시 추가하지 않는다.
## 온라인 인증 시도와 비용 제한 (2026-10-09)
Gateway HTTP server는 기존 전용 Redis와 `GATEWAY_REDIS_PREFIX`에 인증 counter를
공유한다. 별도 credential이나 비활성화 env key는 추가하지 않는다. 각 procedure
실행 전에 원자적으로 모든 counter를 검사하고 허용된 요청만 차감한다. 같은 tRPC
batch의 항목도 각각 차감한다. 기본 창은 최초 허용 요청부터 60초이며 다음과 같다.
| 경계 | 기본 상한 |
| ----------------------------------------------------------------------- | ---------------------- |
| 정규화한 로그인 계정명, peer와 무관 | 10회/60초 |
| 같은 인증 actor의 비밀번호 변경·탈퇴, session을 바꿔도 공유 | 10회/60초 |
| 같은 계정의 게임 세션 발급, 로그인 session/profile을 바꿔도 공유 | 32회/60초 |
| 인증 시작·등록·자격 증명·OTP와 public key/가입 필드 검사, socket peer별 | 600회/60초 |
| 위 인증 procedure 전체, 같은 Gateway Redis namespace | 1200회/60초 |
| RSA/Argon2/provider 호출이 있는 인증 작업, API 프로세스별 | 동시 4개, 대기 큐 없음 |
계정 counter는 존재하지 않는 이름과 성공/실패를 동일하게 차감하고 성공 직후에도
초기화하지 않는다. 거절된 시도는 TTL이나 다른 counter를 갱신하지 않으므로 공격자가
고정된 창을 연장할 수 없다. 계정 자체를 DB에서 잠그거나 sanction을 만들지 않으며
기존 session·OAuth 시작/복구·logout은 계정명 counter와 별도다. 계속되는 공격이
매 새 창의 예산을 소진하면 password login은 일시 제한될 수 있다. account 중심
제한의 가용성 tradeoff로 기록하고 운영자는 인증 정책을 지워서 해결하지 않는다.
Fastify `trustProxy:false`를 유지해 임의 `X-Forwarded-For`로 peer counter를 바꿀 수
없다. 현재 Docker의 Caddy 뒤에서는 이 counter가 해당 proxy를 공유하는 사용자들의
합계다. 이를 인터넷 client별 제한이라고 보고하지 않는다. 원래 client IP를 써야
한다면 실제 두 proxy 경계와 덮어쓰기 규칙, 좁은 trusted proxy allowlist를 먼저
결정해야 하며 `trustProxy:true`나 단순 hop 수를 켜지 않는다. global/account counter와
동시 비용 상한은 forwarded header에 의존하지 않는다.
아직 끝나지 않은 counter/actor 확인은 프로세스당 32개로 제한한다. 요청의 2초
시간 초과 뒤에도 이 자리는 실제 확인 Promise가 끝날 때까지 유지한다. backend가
멈춘 동안 대기하는 payload가 계속 늘어나지 않게 하며 확인이 끝나면 정상 복구한다.
Redis 확인은 2초 안에 끝나야 하고 unready/error/잘못된 counter는 503으로 fail
closed 처리한다. 한도를 넘으면 429와 재시도 초를 포함한 메시지를 보낸다.
Redis namespace 내 key는 purpose를 나눈 HMAC으로 식별해 계정명/IP 원문을 저장하지
않으며 모두 TTL이 있다. key namespace 때문에 secret rotation은 남은 최대 60초의
시도 counter의 key 식별자를 전환한다. 내부 직접 caller/test의 기본 context는
bounded memory store를 사용하지만 실제 HTTP server는 공유 Redis budget을 주입한다.
Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, decoded JSON
응답 64 KiB 상한과 redirect 거절을 적용한다. provider payload·token·account 정보를
오류 문구에 넣지 않는다. `-102 already registered`의 명시적 복구 분기는 유지한다.
여러 요청을 순차 수행하는 OAuth flow의 전체 시간이 10초라는 뜻은 아니다. 상한은
게임 시간·OTP 만료·기존 session lifetime·Argon2 강도와 무관하다.
이 단계는 온라인 자격 증명/가입 비용을 보호하며 established session의 일반 조회,
logout은 이 budget에서 제외한다. 게임 세션 발급은 별도 계정 counter 32회/60초를
사용하고 peer/global counter와 확인 대기 상한을 공유하며 RSA/Argon2 동시 자리에는
들어가지 않는다.
임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의
검증 결과를 실제 공개 배포 근거로 사용하지 않는다.
### 게임 세션 발급·추적 수명 (2026-10-09)
Gateway 로그인 하나가 추적하는 살아 있는 게임 세션은 모든 profile을 합쳐 256개까지
허용한다. 기존 게임 토큰을 강제로 폐기하지 않으며 초과 발급은 HTTP 429다.
발급 전에 만료된 Redis key를 추적 set에서 제거한다. 로그인별 추적 set이 이전
버전에서 이미 256개보다 크다면 전부 펼치지 않고 새 발급을 거절하며, 로그아웃 뒤
다시 로그인하거나 기존 index가 만료된 뒤 복구한다. 정상 다중 탭/여러 profile을
지원하되 발급 자료의 무제한 누적을 허용하지 않는 Core 보안 정책이다.
Redis Lua가 부모 session의 존재·snapshot 일치·만료, 정리·상한과 새 저장을 한 번에
검사한다. 로그아웃은 부모 제거와 최대 64개 게임 key/index 삭제를 원자적으로
반복하여, 발급 경합과 큰 기존 index의 일괄 응답을 방지한다. 부모만 회수하는
`revokeGames:false`는 독립 게임 TTL을 유지한다. 게임 token TTL은 여전히 발급 시점부터
`GAME_SESSION_TTL_SECONDS`이고 부모 로그인 만료가 이미 발급된 게임 token의 만료를
앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로
매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다.
암호화 게임 token의 issuedAt/expiresAt은 권한 조회 시작과 추적 항목 생성 중 더
이른 시각에서 계산한다.
이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로
바꿔 이전 flush를 피하지 않는다. user snapshot 이후 role/sanction 변경과 겹친 발급도
그 flush보다 오래된 credential로 취급한다. 회수 지속성은 아래 별도 Redis 계약을 따른다.
### 게임 token 회수의 지속성 (2026-10-09)
Gateway는 기존 `GATEWAY_REDIS_PREFIX`의 flush channel 아래 `revocation:v1` namespace에
최초 전환 cutoff와 사용자별 마지막 회수 시각을 저장한다. 사용자당 한 값이며
DB/Redis credential, 게임 actor와 profile 경계는 바꾸지 않는다. Lua가 최신 watermark를
보존하고 저장한 뒤 pub/sub을 보내므로 구독자 0명이나 알림 누락도 회수를 되돌리지
않는다. HTTP 인증, 미사용 Gateway token 교환과 SSE 작업 전/쓰기 전이 매번 동일한
Redis watermark와 최초 cutoff를 읽는다. allow 결과를 캐시하지 않고 Redis 조회는
2초 deadline/연결 준비 상태 검사 후 fail closed다. pub/sub은 즉시 연결 종료와
기존 identity/icon durable 동기화를 빠르게 하는 경로다.
진행 중인 회수 Redis 명령은 Gateway publisher당 32개, game API store당 64개로
제한한다. 2초 뒤 호출자가 실패해도 이미 전송된 명령은 실제 완료/연결 실패까지
그 용량을 점유한다. 응답하지 않는 Redis가 caller timeout 뒤에도 계속 명령을
쌓게 하지 않는다. startup 실패는 이 프로세스가 소유한 Redis 연결을 강제 종료한다.
최초 Gateway 활성화 시 `SET NX`로 이전 게임 credential 전체를 회수하는 cutoff를
한 번 만든다. 일반 Gateway session은 유지하므로 사용자는 Gateway에서 새 game token을
받아 재접속한다. 반복 기동은 cutoff를 보존한다. 이 전환은 이전 메모리에서만 알고
있던 회수 이력을 추측하여 이관하지 않고 기존 credential을 폐기하는 보안 정책이다.
Gateway publisher를 먼저 갱신한 뒤 모든 game API를 갱신한다. 새 game API는 초기화
marker가 없거나 잘못됐으면 기동을 거절하며 실행 중 조회 장애/marker 누락도 인증을
허용하지 않는다. 여러 Gateway writer가 있으면 모두 새 publisher 계약을 적용해야 한다.
cutoff/watermark는 TTL로 삭제하지 않는다. game TTL 설정을 줄인 뒤 과거의 더 긴
token이 다시 살아나는 것을 막는다. 저장량은 회수한 사용자 수에 비례하는 작은
metadata이며 Redis의 기존 persistence/volume에 보존한다. 회수 key 수명 정리는
모든 기존 credential의 만료를 증명한 별도 유지보수 작업이다. watermark 삭제,
Redis 복원/namespace 변경을 일반 restart와 같은 것으로 처리하지 않는다.
Gateway DB 변경과 Redis 회수는 하나의 분산 transaction이 아니다. DB 변경 뒤 회수
저장이 실패하면 API는 성공을 반환하지 않으며 운영자는 partial progress와 재시도를
확인해야 한다. 이 계약은 성공한 회수의 pub/sub 누락·API 재시작 내구성을 보장하며,
DB/Redis backup의 시점 불일치나 이미 허용된 in-flight mutation을 자동 취소하는
정책으로 확대하지 않는다. 기존 대기 frame/identity 동기화는 별도 기존 경계를 유지한다.
### Builder 요청 인증과 자원 경계 (2026-10-09)
`RELEASE_BUILDER_URL`을 설정한 원격 build는 전용 `RELEASE_BUILDER_TOKEN_FILE`이
필요합니다. 파일의 무작위 token은 `x-release-builder-token` 헤더로만 전달하며
명령·progress event·`VITE_*`에 넣지 않습니다. 파일 누락/오류는 build 실패이며
로컬 runner로 우회하지 않습니다. HTTP redirect를 따라가지 않고 오류 response의
원문 body도 릴리스 로그에 복사하지 않습니다.
Docker builder와 runtime에 같은 파일을 read-only mount합니다. host 파일은 Git
제외·mode 0600으로 보존하고 DB/Redis/OAuth/bootstrap token과 공유하지 않습니다.
이 경계는 신뢰하는 Core repository와 dependency의 build script를 실행하기 위한
인증입니다. 악의적인 repository code를 OS 수준으로 격리하는 sandbox가 아니며
builder 파일 시스템에 있는 builder 전용 token은 신뢰하는 build code가 읽을 수
있습니다. 다른 운영 credential은 builder에 mount하거나 child env로 전달하지 않습니다.
Builder는 허용된 package의 frozen install·build·Prisma generate·frontend materialize만
실행하며 임의 task/flag, cwd/cache의 탈출 symlink와 Node preload 옵션을 거부합니다.
동시에 실행하는 job은 하나이며 실행·대기·body 읽기를 합쳐 3개까지만 받습니다.
초과 요청은 429이고, byte 기준 body 256 KiB/읽기 5초, job 전체 30분,
NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를 상한으로 둡니다.
취소·deadline·response 중단은 자식 process group을 TERM/KILL하며 실제 종료까지
대기 자리를 보존합니다. Core client도 30분 deadline과 같은 frame/stream 상한을
적용하고 progress/result의 실제 type과 길이를 검사합니다.
기존 무인증 builder에서 전환할 때는 다음 순서로 작업합니다.
1. 전용 token 파일을 준비하고 현재 이미지에 mount·환경 경로만 추가합니다.
2. 기존 builder를 유지한 채 새 Core controller self-upgrade와 Gateway UPDATE를
수행해 새 인증 client를 활성화합니다. 기존 builder는 추가 헤더를 허용합니다.
3. 인증을 강제하는 새 Docker builder와 bootstrap client를 함께 build·재생성합니다.
4. 인증 없는 POST가 401이고 정상 bootstrap/release가 성공하는지 확인합니다.
새 builder를 먼저 적용하면 구형 Core/구형 Docker bootstrap client의 요청이 401로
실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
### 이미지 업로드와 WebPush 전송 경계
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,
계정 아이콘의 50 KiB·64~128px 정사각형·활성 5개 정책은 유지한다.
서버 자원 보호를 위해 게시판은 총 16,777,216 입력 픽셀, 아이콘은 총 4,194,304
입력 픽셀, 양쪽은 최대 256 프레임을 허용한다. raster signature를 먼저 확인하고
Sharp의 `limitInputPixels`와 처리 timeout 10초를 적용한다. 각 API 프로세스의
이미지 작업은 최대 4개이며 대기열 없이 초과 요청을 `TOO_MANY_REQUESTS`로 거절한다.
실제 작업이 끝나기 전에는 슬롯을 반환하지 않는다. 이 제한은 일일 업로드 횟수나
아이콘 소유권 정책을 변경하지 않는다. 기존 Fastify HTTP body limit도 유지한다.
공통 server 전용 `@sammo-ts/common/images/imageUpload`가 두 이미지 저장 adapter의
서명 PUT 전송을 처리한다. 기존 60초 HMAC·path/body binding을 유지하며 redirect를
거부하고, 요청부터 응답 완료까지 10초, JSON 응답 16 KiB, 전송 body 16 MiB를
제한한다. 실제 streaming bytes를 세므로 Content-Length가 없거나 부정확해도 제한이
적용된다. 응답 path만 검증하고 공개 URL은 설정한 public base에서 생성한다.
abort를 무시하는 작업도 실제 종료까지 최대 4개 전송 슬롯을 점유한다.
저장소 실패는 API에서 `SERVICE_UNAVAILABLE`, 작업 포화는 `TOO_MANY_REQUESTS`이다.
WebPush는 `web-push.generateRequestDetails`의 암호화·VAPID·TTL을 유지하면서
`sendNotification.ts`에서 HTTPS 전송을 직접 수행한다. 기존 public DNS/socket lookup
정책을 유지하고 redirect를 따르지 않는다. 전체 요청 10초와 실제 응답 16 KiB를
제한하며 응답 body를 저장하지 않는다. HTTP status는 기존 404/410 subscription
비활성화와 재시도 판단에 전달된다. 일반 전송 오류는 일시 재시도 대상으로 유지한다.
이 값에는 추가 `.env`나 credential이 필요 없다. VAPID가 없는 설치의 WebPush는
기존대로 비활성 상태이며, 이 경우 실제 외부 push 성공 검증을 대신하지 않는다.
+48
View File
@@ -0,0 +1,48 @@
# 토너먼트의 사용자·장수 권한과 운영 전환
참가와 베팅의 actor는 인증된 사용자이며 장수 ID는 actor의 현재 소유 자원입니다.
API 조회와 ENGINE 실행 사이에 소유권이 바뀔 수 있으므로
`tournamentAdjustGeneral`은 `userId`와 `generalId`를 함께 전달하고 durable
`InputEvent.actorUserId` 및 실행 시점 world의 소유자를 다시 검사합니다. 다른
소유자의 금·통계는 차감과 보상 모두 변경하지 않습니다.
참가비는 현재 개발비, 베팅은 최소 10금·사용자별 합계 1000금·차감 후 500금
이상을 유지합니다. 금과 `betgold`는 한 ENGINE event에서 갱신합니다. 베팅은
기존처럼 SUSPENDED의 동결된 게임 시각에도 허용하고 참가 명령은 허용하지
않습니다. RECONCILING에서는 베팅 금융 명령을 새로 claim하지 않습니다.
Redis 베팅 row의 `userId`는 서버가 인증 session에서 기록합니다. 사용자별
한도와 개인 합계는 장수 교체 후에도 이 ID로 검사합니다. `generalId`·`targetId`가
같아도 사용자가 다르면 row를 합치지 않습니다. NPC row는 `userId: null`, 구형
row는 field 생략으로 구분합니다. 당첨금·취소 환급의 장수 ID 기준 자산 정산은
Ref의 기존 계약을 유지합니다. 당시 사용자의 개인 표시와 장수의 자산 귀속은
동일한 개념이 아닙니다.
NPC worker는 deterministic 계획을 먼저 저장하고 `tournamentSeedNpcBets`의
durable 결과를 기다립니다. ENGINE은 유저가 소유하게 된 NPC를 건너뛰고 남은
NPC의 금·통계를 함께 처리합니다. worker는 실제 처리된 ID만 베팅 projection에
기록한 뒤 stage 6을 공개합니다. 응답이 불확실하면 같은 request ID와 계획으로
재시도하며 NPC를 새로 선택하지 않습니다. 계획/RNG 생성 순서는 유지합니다.
## 구형 설치에서 전환
API·ENGINE·tournament worker를 같은 변경 세대로 전환해야 합니다. 새 API만
배포하면 구형 ENGINE은 새 명령을 처리할 수 없고, 구형 API만 남기면 새 ENGINE은
actor 없는 구형 토너먼트 `adjustGeneralResources/Meta` 명령을 거부합니다.
종료된 베팅과 정상 완료된 큐를 확인한 뒤 전환하는 것을 기본으로 합니다.
현재 owner로 구형 row의 당시 사용자를 추정하지 않습니다. 사용자 ID가 없는 row는
전체 풀·기존 정산에 유지하지만 개인 합계에 표시하지 않으며, 해당 베팅에 신규
베팅을 허용하지 않습니다. 활성 베팅을 유지해야 한다면 관리자가 당시 사용자
정보를 확인한 row만 `setBettingEntries`로 복원하고 NPC에는 null을 지정합니다.
근거가 없는 row를 현재 owner로 자동 backfill하거나 큐/베팅을 임의 삭제하지 않습니다.
이전 버전의 NPC 비용/통계 두 명령이 일부만 실행된 상태라면 동일 계획의 새
atomic 명령을 바로 재실행하지 않습니다. 기존 InputEvent 결과와 자원·통계를
대조하여 완료 여부를 먼저 복구해야 합니다. 이 변경은 자동 saga migration을
제공하지 않습니다.
ENGINE transaction과 Redis projection은 별도 저장소입니다. projection 쓰기 실패
뒤 compensation을 시도하지만 그 사이 소유자가 바뀌면 다른 owner의 자원을
수정하지 않도록 거부합니다. 전체 PostgreSQL/Redis crash recovery와 자동
reconciliation을 보장하지 않으며 기존 saga의 한계는 유지됩니다.
+8
View File
@@ -14,6 +14,10 @@
"types": "./dist/auth/gameToken.d.ts",
"default": "./dist/auth/gameToken.js"
},
"./auth/sessionRevocation": {
"types": "./dist/auth/sessionRevocation.d.ts",
"default": "./dist/auth/sessionRevocation.js"
},
"./auth/sanctions": {
"types": "./dist/auth/sanctions.d.ts",
"default": "./dist/auth/sanctions.js"
@@ -57,6 +61,10 @@
"./util/JosaUtil": {
"types": "./dist/util/JosaUtil.d.ts",
"default": "./dist/util/JosaUtil.js"
},
"./images/imageUpload": {
"types": "./dist/images/imageUpload.d.ts",
"default": "./dist/images/imageUpload.js"
}
},
"scripts": {
@@ -0,0 +1,55 @@
// Gateway/game API는 같은 Redis namespace의 회수 계약을 공유한다.
// 최초 전환 cutoff와 사용자 watermark는 token TTL 설정 변화에도 유효해야 하므로
// TTL로 지우지 않는다. 사용자당 한 값이며 삭제는 모든 기존 token 만료 확인 뒤 운영 작업이다.
export const buildSessionRevocationBaselineKey = (channel: string): string => `${channel}:revocation:v1:baseline`;
export const buildUserSessionRevocationKey = (channel: string, userId: string): string =>
`${channel}:revocation:v1:user:${userId}`;
export const parseSessionRevocationWatermark = (raw: string | null): Date | null => {
if (raw === null) return null;
if (!/^\d{1,16}$/.test(raw)) throw new Error('Invalid session revocation state.');
const value = Number(raw);
if (!Number.isSafeInteger(value) || value > 8_640_000_000_000_000)
throw new Error('Invalid session revocation state.');
return new Date(value);
};
/** A caller timeout does not prove a command already sent to Redis has settled. */
export class SessionRevocationCommandBudget {
private pending = 0;
constructor(private readonly maxPending = 64) {}
async run<T>(start: () => Promise<T>): Promise<T> {
if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.');
this.pending += 1;
let command: Promise<T>;
try {
command = start();
} catch {
this.pending -= 1;
throw new Error('Session revocation storage is unavailable.');
}
void command.then(
() => {
this.pending -= 1;
},
() => {
this.pending -= 1;
}
);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
command,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Session revocation storage is unavailable.')), 2000);
timeout.unref();
}),
]);
} catch {
throw new Error('Session revocation storage is unavailable.');
} finally {
clearTimeout(timeout);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
const MAX_ORIGINS = 32;
const MAX_ORIGIN_LENGTH = 2048;
const parsePublicUrl = (value: string): URL => {
if (value.length > MAX_ORIGIN_LENGTH) throw new Error('API origin configuration is too long.');
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('API origins require a public HTTP(S) URL without credentials.');
}
return url;
};
/** Public application URLs supply the default origins; extra origins must be explicit. */
export const resolveApiAllowedOrigins = (
extraOrigins: string | undefined,
publicUrls: readonly (string | undefined)[]
): string[] => {
const origins = new Set<string>();
for (const value of publicUrls) {
if (value?.trim()) origins.add(parsePublicUrl(value.trim()).origin);
}
if (extraOrigins?.trim()) {
const entries = extraOrigins.split(',');
if (entries.length > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
for (const value of entries) {
const url = parsePublicUrl(value.trim());
if (url.pathname !== '/' || url.search || url.hash) {
throw new Error('API_ALLOWED_ORIGINS entries must be origins, without paths or queries.');
}
origins.add(url.origin);
}
}
if (origins.size > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
return [...origins];
};
class ApiOriginForbiddenError extends Error {
readonly statusCode = 403;
constructor() {
super('Request origin is not allowed.');
}
}
/** Reject browser requests before parsing bodies or dispatching mutations, not just CORS responses. */
export const createApiOriginGuard = (allowedOrigins: readonly string[]) => {
const allowed = new Set(allowedOrigins);
return async (request: { headers: { origin?: string | string[] } }): Promise<void> => {
const origin = request.headers.origin;
// Internal machine clients have no Origin and still require their ordinary auth/HMAC.
if (origin === undefined) return;
if (typeof origin !== 'string' || origin.length > MAX_ORIGIN_LENGTH || !allowed.has(origin)) {
throw new ApiOriginForbiddenError();
}
};
};
+31
View File
@@ -0,0 +1,31 @@
/** Preserve public validation/permission messages while hiding internal causes and stacks. */
export const safeTrpcErrorFormatter = <T extends { message: string; data: { code: string; stack?: string } }>({
shape,
}: {
shape: T;
}): T => ({
...shape,
message: shape.data.code === 'INTERNAL_SERVER_ERROR' ? 'Internal server error' : shape.message,
data: { ...shape.data, stack: undefined },
});
export const safeHttpErrorHandler = (
error: unknown,
request: { log: { error: (context: object, message: string) => void } },
reply: { code: (status: number) => { send: (body: object) => unknown } }
): void => {
const value = error && typeof error === 'object' ? (error as { statusCode?: number; message?: string }) : {};
const status =
typeof value.statusCode === 'number' &&
Number.isInteger(value.statusCode) &&
value.statusCode >= 400 &&
value.statusCode < 500
? value.statusCode
: 500;
request.log.error({ err: error }, 'Request handling failed');
reply.code(status).send({
statusCode: status,
error: status >= 500 ? 'Internal Server Error' : 'Request Error',
message: status >= 500 ? 'Internal server error' : (value.message ?? 'Request failed'),
});
};
+75
View File
@@ -0,0 +1,75 @@
const errorCodes = /^(?:P\d{4}|FST_ERR_[A-Z_]+|ERR_[A-Z_]+|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|INTERNAL_SERVER_ERROR)$/;
const errorSummary = (
error: unknown
): { type: string; message: string; stack: string; code?: string; statusCode?: number } => {
const value = error && typeof error === 'object' ? (error as Record<string, unknown>) : {};
const code =
typeof value.code === 'string' && value.code.length < 80 && errorCodes.test(value.code)
? value.code
: undefined;
const statusCode =
typeof value.statusCode === 'number' && value.statusCode >= 400 && value.statusCode <= 599
? value.statusCode
: undefined;
return {
type: 'RequestError',
message: 'Request handling failed',
stack: '',
...(code ? { code } : {}),
...(statusCode ? { statusCode } : {}),
};
};
/** HTTP diagnostics carry static route/status/code, never credentials or error payloads. */
export const safeHttpLoggerOptions = {
serializers: {
req: (request: { method?: string; routeOptions?: { url?: string } }) => ({
method: request.method,
// URLs can carry OAuth/session/input values, including unmatched path segments.
url: request.routeOptions?.url ?? '[unmatched]',
}),
res: (response: { statusCode?: number }) => ({ statusCode: response.statusCode }),
err: errorSummary,
},
redact: {
paths: [
'authorization',
'cookie',
'password',
'sessionToken',
'accessToken',
'token',
'secret',
'body',
'headers',
'req.headers',
'req.body',
'res.headers',
],
remove: true,
},
hooks: {
logMethod(args: unknown[], method: (...values: unknown[]) => void): void {
const first = args[0];
const error =
first instanceof Error
? first
: first && typeof first === 'object' && 'err' in first
? first.err
: undefined;
if (error !== undefined) {
// Fastify also copies err.message into msg; serializers alone do not protect it.
method.apply(this, [{ err: errorSummary(error) }, 'Request handling failed']);
} else {
// Fastify's default 404 handler also embeds the raw URL in a message.
method.apply(
this,
args.map((value) =>
typeof value === 'string' && /^Route .+ not found$/.test(value) ? 'Route not found' : value
)
);
}
},
},
};
+143
View File
@@ -0,0 +1,143 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
export class ImageWorkBusyError extends Error {
constructor() {
super('Image service is busy.');
}
}
/** Admission lasts until actual work settles, including work that ignores abort. No queue. */
export class ImageWorkBudget {
private pending = 0;
constructor(private readonly limit = 4) {}
async run<T>(work: () => Promise<T>): Promise<T> {
if (this.pending >= this.limit) throw new ImageWorkBusyError();
this.pending++;
try {
return await work();
} finally {
this.pending--;
}
}
}
export class ImageUploadError extends Error {}
const uploadBudget = new ImageWorkBudget();
export const MAX_IMAGE_RESPONSE_BYTES = 16 * 1024;
export const IMAGE_UPLOAD_TIMEOUT_MS = 10_000;
export const isRasterImage = (body: Buffer): boolean =>
body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
(body[0] === 255 && body[1] === 216 && body[2] === 255) ||
['GIF87a', 'GIF89a'].includes(body.toString('ascii', 0, 6)) ||
(body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') ||
body.toString('ascii', 0, 2) === 'BM' ||
['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(body.subarray(0, 4).toString('hex')) ||
(body.length >= 12 && body.toString('ascii', 4, 8) === 'ftyp');
export const performSignedImageUpload = async (
input: {
kind: 'content' | 'user-icons';
baseUrl: string;
filename: string;
contentType: string;
body: Buffer;
secret: string;
},
fetchImpl: typeof fetch = fetch,
now: () => number = Date.now,
options: { timeoutMs?: number; budget?: ImageWorkBudget } = {}
): Promise<void> => {
const base = new URL(input.baseUrl);
if (
!['http:', 'https:'].includes(base.protocol) ||
base.username ||
base.password ||
base.search ||
base.hash ||
!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename) ||
input.body.length > 16 * 1024 * 1024
) {
throw new ImageUploadError('Invalid image upload.');
}
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const work = (options.budget ?? uploadBudget).run(async () => {
const pathname = `/v1/uploads/${input.kind}/core2026/${input.filename}`;
const expires = String(Math.floor(now() / 1000) + 60);
const requestId = randomUUID();
const digest = createHash('sha256').update(input.body).digest('hex');
const signature = createHmac('sha256', input.secret)
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
.digest('hex');
const response = await fetchImpl(`${input.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
redirect: 'error',
signal: controller.signal,
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
},
body: new Uint8Array(input.body),
});
if (controller.signal.aborted || !response.ok) {
await response.body?.cancel();
throw new ImageUploadError(
controller.signal.aborted
? 'Image upload timed out.'
: `Image repository upload failed with HTTP ${response.status}.`
);
}
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_IMAGE_RESPONSE_BYTES) {
await response.body?.cancel();
throw new ImageUploadError('Image response exceeds its size limit.');
}
const reader = response.body?.getReader();
if (!reader) throw new ImageUploadError('Image repository returned an invalid response.');
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const chunk = await reader.read();
if (controller.signal.aborted) throw new ImageUploadError('Image upload timed out.');
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > MAX_IMAGE_RESPONSE_BYTES)
throw new ImageUploadError('Image response exceeds its size limit.');
chunks.push(chunk.value);
}
} catch (error) {
await reader.cancel();
throw error;
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const expected =
input.kind === 'content' ? `uploads/core2026/${input.filename}` : `icons/users/core2026/${input.filename}`;
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expected) {
throw new ImageUploadError('Image repository returned an unexpected upload path.');
}
});
try {
await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new ImageUploadError('Image upload timed out.'));
}, options.timeoutMs ?? IMAGE_UPLOAD_TIMEOUT_MS);
}),
]);
} catch (error) {
if (error instanceof ImageWorkBusyError || error instanceof ImageUploadError) throw error;
// Request errors can contain credential-bearing URLs and signatures.
throw new ImageUploadError('Image repository upload failed.');
} finally {
clearTimeout(timer);
}
};
+3
View File
@@ -31,6 +31,9 @@ export * from './gateway/profileStatus.js';
export * from './gateway/runtimeDiagnostics.js';
export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js';
export * from './http/safeLogger.js';
export * from './http/errorPrivacy.js';
export * from './http/apiOrigin.js';
export * from './webPush/types.js';
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
+37
View File
@@ -307,6 +307,21 @@ export type TurnDaemonCommand =
| { kind: 'nationPriority'; priority: string[] }
| { kind: 'generalPriority'; priority: string[] };
}
| {
type: 'tournamentAdjustGeneral';
requestId?: string;
reason: 'tournamentJoin' | 'tournamentJoinRollback' | 'tournamentBet' | 'tournamentBetRollback';
userId: string;
generalId: number;
goldDelta: number;
betGoldDelta?: number;
minGoldAfter: number;
}
| {
type: 'tournamentSeedNpcBets';
requestId?: string;
bets: Array<{ generalId: number; amount: number }>;
}
| {
type: 'adjustGeneralResources';
requestId?: string;
@@ -701,6 +716,28 @@ export type TurnDaemonCommandResult =
nationId?: number;
currentUpdatedAt?: string | null;
}
| {
type: 'tournamentAdjustGeneral';
ok: true;
generalId: number;
}
| {
type: 'tournamentAdjustGeneral';
ok: false;
code: 'FORBIDDEN' | 'BAD_REQUEST';
reason: string;
}
| {
type: 'tournamentSeedNpcBets';
ok: true;
processedGeneralIds: number[];
skippedGeneralIds: number[];
}
| {
type: 'tournamentSeedNpcBets';
ok: false;
reason: string;
}
| {
type: 'adjustGeneralResources';
ok: true;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { createApiOriginGuard, resolveApiAllowedOrigins } from '../src/http/apiOrigin.js';
describe('public API origin policy', () => {
it('derives exact origins from application URLs and explicit extra origins', async () => {
const origins = resolveApiAllowedOrigins('http://localhost:5173,https://sam.hided.net', [
'https://sam.hided.net/gateway/',
'https://sam.hided.net/che/',
]);
expect(origins).toEqual(['https://sam.hided.net', 'http://localhost:5173']);
const guard = createApiOriginGuard(origins);
await expect(guard({ headers: {} })).resolves.toBeUndefined();
for (const origin of origins) await expect(guard({ headers: { origin } })).resolves.toBeUndefined();
for (const origin of [
'https://untrusted.example',
'https://sam.hided.net.untrusted.example',
'http://sam.hided.net',
'https://sam.hided.net:8443',
'null',
'',
'https://sam.hided.net,https://untrusted.example',
'x'.repeat(2049),
['https://sam.hided.net', 'https://untrusted.example'],
]) {
await expect(guard({ headers: { origin } })).rejects.toMatchObject({ statusCode: 403 });
}
});
it.each([
'*',
'null',
'https://user:password@example.com',
'file:///tmp/example',
'https://x.test/path',
'https://x.test?query',
'https://x.test#fragment',
'https://x.test,',
])('rejects unsafe configuration %s', (value) => {
expect(() => resolveApiAllowedOrigins(value, [])).toThrow();
});
it('bounds configuration and fails closed when no public origin is configured', async () => {
expect(() =>
resolveApiAllowedOrigins(Array.from({ length: 33 }, (_, i) => `https://${i}.test`).join(','), [])
).toThrow();
expect(() => resolveApiAllowedOrigins('https://' + 'a'.repeat(2048) + '.test', [])).toThrow();
await expect(
createApiOriginGuard([])({ headers: { origin: 'https://untrusted.example' } })
).rejects.toMatchObject({ statusCode: 403 });
});
});
+99
View File
@@ -0,0 +1,99 @@
import { createServer, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { ImageWorkBudget, performSignedImageUpload } from '../src/images/imageUpload.js';
const input = {
kind: 'content' as const,
baseUrl: 'https://image.example',
filename: `${'a'.repeat(32)}.png`,
contentType: 'image/png',
body: Buffer.from('test'),
secret: 'synthetic-image-secret',
};
const payload = () => new Response(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
describe('bounded signed image transport', () => {
it('never forwards signed PUT bodies across a real redirect', async () => {
let forwarded = 0;
const target = createServer((_, res) => {
forwarded++;
res.end(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
});
const targetUrl = await listen(target);
const origin = createServer((_, res) => {
res.writeHead(307, { location: targetUrl });
res.end();
});
const baseUrl = await listen(origin);
try {
await expect(performSignedImageUpload({ ...input, baseUrl })).rejects.toThrow();
expect(forwarded).toBe(0);
} finally {
await close(origin);
await close(target);
}
});
it('bounds streamed bytes independently of Content-Length', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10_000));
controller.enqueue(new Uint8Array(10_000));
},
});
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow('size limit');
});
it('rejects wrong and malformed JSON paths', async () => {
for (const body of ['{', JSON.stringify({ path: 'elsewhere' })]) {
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow();
}
});
it('keeps timed-out non-cooperative work admitted until it actually settles', async () => {
const budget = new ImageWorkBudget(1);
let resolve!: (response: Response) => void;
const fetchImpl: typeof fetch = () =>
new Promise<Response>((done) => {
resolve = done;
});
await expect(performSignedImageUpload(input, fetchImpl, Date.now, { timeoutMs: 25, budget })).rejects.toThrow(
'timed out'
);
await expect(performSignedImageUpload(input, async () => payload(), Date.now, { budget })).rejects.toThrow(
'busy'
);
resolve(payload());
await new Promise<void>((done) => setImmediate(done));
await expect(
performSignedImageUpload(input, async () => payload(), Date.now, { budget })
).resolves.toBeUndefined();
});
it.each(['headers', 'body'])(
'aborts a real stalled %s response within the whole request deadline',
async (phase) => {
const server = createServer((_, res) => {
if (phase === 'body') {
res.writeHead(201);
res.write('{');
}
});
const baseUrl = await listen(server);
try {
await expect(
performSignedImageUpload({ ...input, baseUrl }, fetch, Date.now, { timeoutMs: 100 })
).rejects.toThrow('timed out');
} finally {
await close(server);
}
}
);
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { SessionRevocationCommandBudget, parseSessionRevocationWatermark } from '../src/auth/sessionRevocation.js';
describe('session revocation command ownership', () => {
it('holds capacity after caller timeout until the actual command settles, then recovers', async () => {
vi.useFakeTimers();
const budget = new SessionRevocationCommandBudget(1);
let finish!: () => void;
const actual = new Promise<void>((resolve) => {
finish = resolve;
});
try {
const timed = expect(budget.run(() => actual)).rejects.toThrow('unavailable');
await vi.advanceTimersByTimeAsync(2000);
await timed;
const extra = vi.fn(async () => 'extra');
await expect(budget.run(extra)).rejects.toThrow('unavailable');
expect(extra).not.toHaveBeenCalled();
finish();
await actual;
await expect(budget.run(extra)).resolves.toBe('extra');
} finally {
finish();
vi.useRealTimers();
}
});
it('releases capacity after synchronous startup or asynchronous backend failure', async () => {
const budget = new SessionRevocationCommandBudget(1);
await expect(
budget.run(() => {
throw new Error('synthetic');
})
).rejects.toThrow('unavailable');
await expect(
budget.run(async () => {
throw new Error('synthetic');
})
).rejects.toThrow('unavailable');
await expect(budget.run(async () => 'ok')).resolves.toBe('ok');
});
it('validates numeric persisted timestamps while distinguishing absent state', () => {
expect(parseSessionRevocationWatermark(null)).toBeNull();
expect(parseSessionRevocationWatermark('0')?.getTime()).toBe(0);
expect(() => parseSessionRevocationWatermark('NaN')).toThrow();
expect(() => parseSessionRevocationWatermark('8640000000000001')).toThrow();
});
});
+2
View File
@@ -4,8 +4,10 @@ import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mj
export default defineConfig({
entry: {
index: 'src/index.ts',
'images/imageUpload': 'src/images/imageUpload.ts',
'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',
'auth/sanctions': 'src/auth/sanctions.ts',
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
'http/trpcTransport': 'src/http/trpcTransport.ts',
+17
View File
@@ -384,6 +384,9 @@ importers:
es-toolkit:
specifier: ^1.43.0
version: 1.51.0
eventsource:
specifier: 5.1.2
version: 5.1.2
mitt:
specifier: ^3.0.1
version: 3.0.1
@@ -3788,6 +3791,14 @@ packages:
eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
eventsource-parser@4.1.1:
resolution: {integrity: sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw==}
engines: {node: '>=22.12'}
eventsource@5.1.2:
resolution: {integrity: sha512-VJnwqqp+fOT3WDkjnUy49Zuyh48+kH0JdJApZkslrRQyx35zIZJeJYJl0+a+LrTzh5TFv6MdmXnBDv71UXFyWQ==}
engines: {node: '>=22.12.0'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -7943,6 +7954,12 @@ snapshots:
eventemitter2@6.4.9: {}
eventsource-parser@4.1.1: {}
eventsource@5.1.2:
dependencies:
eventsource-parser: 4.1.1
expect-type@1.4.0: {}
exsolve@1.0.8: {}