diff --git a/.env.example b/.env.example index 44d5b700..5e4917d4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.nvmrc b/.nvmrc index df6ae337..ce62013c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.21.0 +24.22.1 diff --git a/README.md b/README.md index ef004d55..2824feb9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/game-api/src/auth/flushStore.ts b/app/game-api/src/auth/flushStore.ts index 41ab28af..e17a32c1 100644 --- a/app/game-api/src/auth/flushStore.ts +++ b/app/game-api/src/auth/flushStore.ts @@ -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; 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(); + 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 { + 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 { + 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; diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index c9125420..fdc77f05 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -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', diff --git a/app/game-api/src/realtime/authenticatedConnection.ts b/app/game-api/src/realtime/authenticatedConnection.ts new file mode 100644 index 00000000..7d7960e9 --- /dev/null +++ b/app/game-api/src/realtime/authenticatedConnection.ts @@ -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; + private cleanup = new Set<() => void>(); + + constructor( + readonly auth: GameSessionTokenPayload, + private readonly response: ServerResponse, + private readonly resolveAuth: () => Promise + ) { + // 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 { + return this.queue; + } + + onClose(cleanup: () => void): void { + if (this.ended) cleanup(); + else this.cleanup.add(cleanup); + } + + private async currentAuth(): Promise { + 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): 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(); + }; +} diff --git a/app/game-api/src/realtime/connectionAdmission.ts b/app/game-api/src/realtime/connectionAdmission.ts new file mode 100644 index 00000000..9c235693 --- /dev/null +++ b/app/game-api/src/realtime/connectionAdmission.ts @@ -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(); + 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(); + } +} diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index cc77befc..6c8b1f25 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -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', diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index 4903a54c..c1f96f70 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -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 => { - 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 => { - 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 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, - }; - }), + }), }); diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 12216a3d..b91bace7 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -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 }; diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index c82830c7..11505a11 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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 => { 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(); + 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 => { + 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 => { - 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 => { + 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) => { diff --git a/app/game-api/src/services/remoteContentImageStore.ts b/app/game-api/src/services/remoteContentImageStore.ts index 6dd2ec25..3a5658a3 100644 --- a/app/game-api/src/services/remoteContentImageStore.ts +++ b/app/game-api/src/services/remoteContentImageStore.ts @@ -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}` }; } } diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 277c0d64..14ea0f72 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -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 { 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; diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index f464b24b..db5e6237 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -74,6 +74,8 @@ export interface TournamentMatchEntry { export interface TournamentBetEntry { generalId: number; + // 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다. + userId?: string | null; targetId: number; amount: number; } diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 6e1ff7ee..22b7800e 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -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 })) + ) + ); }; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 1745bfbd..e65482d1 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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().create(); +const t = initTRPC.context().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', diff --git a/app/game-api/test/authenticatedRealtimeConnection.test.ts b/app/game-api/test/authenticatedRealtimeConnection.test.ts new file mode 100644 index 00000000..0dda5784 --- /dev/null +++ b/app/game-api/test/authenticatedRealtimeConnection.test.ts @@ -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, 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((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); + }); +}); diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts index 298b4d8e..61b48a64 100644 --- a/app/game-api/test/boardRouter.test.ts +++ b/app/game-api/test/boardRouter.test.ts @@ -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('').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}`, diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts index 3c58ee52..812b10e7 100644 --- a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -68,6 +68,7 @@ const restoreEnv = (): void => { const deleteProfileRedisKeys = async (): Promise => { 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); diff --git a/app/game-api/test/flushStore.test.ts b/app/game-api/test/flushStore.test.ts index 72f34742..386fa2b9 100644 --- a/app/game-api/test/flushStore.test.ts +++ b/app/game-api/test/flushStore.test.ts @@ -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(); + 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((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((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 2d8f4dd5..21ab048d 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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; diff --git a/app/game-api/test/generalActivityTracking.test.ts b/app/game-api/test/generalActivityTracking.test.ts index 2cb5dfc8..b78218e6 100644 --- a/app/game-api/test/generalActivityTracking.test.ts +++ b/app/game-api/test/generalActivityTracking.test.ts @@ -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({ diff --git a/app/game-api/test/httpErrorPrivacy.test.ts b/app/game-api/test/httpErrorPrivacy.test.ts new file mode 100644 index 00000000..1b75cf1c --- /dev/null +++ b/app/game-api/test/httpErrorPrivacy.test.ts @@ -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(); + } + }); +}); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index 4a722d20..85863c79 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -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 () => ({ diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts index ad6f28c1..8e235ae0 100644 --- a/app/game-api/test/mainRecordsRouter.test.ts +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -36,6 +36,7 @@ type LogQuery = { const buildContext = (findMany: (query: LogQuery) => Promise>) => ({ auth, + profile: { id: 'che', name: 'che:default', scenario: 'default' }, db: { general: { findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ diff --git a/app/game-api/test/nationHtmlTransport.integration.test.ts b/app/game-api/test/nationHtmlTransport.integration.test.ts index 0d83c391..d11d6e92 100644 --- a/app/game-api/test/nationHtmlTransport.integration.test.ts +++ b/app/game-api/test/nationHtmlTransport.integration.test.ts @@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise => { 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); diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 56727a58..fac43e6c 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -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, diff --git a/app/game-api/test/realtimeConnectionAdmission.test.ts b/app/game-api/test/realtimeConnectionAdmission.test.ts new file mode 100644 index 00000000..aa654c95 --- /dev/null +++ b/app/game-api/test/realtimeConnectionAdmission.test.ts @@ -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((resolve) => { + finish = resolve; + }); + const running = new Promise((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(); + }); +}); diff --git a/app/game-api/test/remoteContentImageStore.test.ts b/app/game-api/test/remoteContentImageStore.test.ts index ce2e63e8..dc592d5c 100644 --- a/app/game-api/test/remoteContentImageStore.test.ts +++ b/app/game-api/test/remoteContentImageStore.test.ts @@ -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(); + }); }); diff --git a/app/game-api/test/safeHttpLogger.test.ts b/app/game-api/test/safeHttpLogger.test.ts new file mode 100644 index 00000000..2ee0f89b --- /dev/null +++ b/app/game-api/test/safeHttpLogger.test.ts @@ -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(); + } + }); +}); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 2e00a6a5..e43ca296 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -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 => { 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> => { if (!redis) return []; const keys = new Set(); - 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>[] = []; + 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((resolve) => { + unlock = resolve; + }); + let ready!: (tx: GamePrisma.TransactionClient) => void; + let lockError!: (error: unknown) => void; + const locked = new Promise((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>` + 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. diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 75932a7a..40c89105 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport { async requestCommand(command: TurnDaemonCommand): Promise { 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({ diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 9281c1e3..954809b7 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record => 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) })]), }) ); } diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index bdca37b3..1a71b603 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -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 ( diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 5a8ac8c0..1b286cd8 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -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, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 8c59ffe7..20e61e6a 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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 +): Promise { + 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 +): Promise { + 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 ): Promise { 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 ): Promise { 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), setNpcPolicy: (command) => handleSetNpcPolicy(ctx, command as Extract), + tournamentAdjustGeneral: (command) => + handleTournamentAdjustGeneral( + ctx, + command as Extract + ), + tournamentSeedNpcBets: (command) => + handleTournamentSeedNpcBets(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources( ctx, diff --git a/app/game-engine/test/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts index e0b37da3..fdee7018 100644 --- a/app/game-engine/test/authenticatedActorCommand.test.ts +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -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 }, diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 95040467..0ab985ca 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -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, diff --git a/app/game-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts index 571cf129..057144e7 100644 --- a/app/game-engine/test/tournamentCommands.test.ts +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -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, diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index e40ccf26..435dda0e 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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; + 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({ + 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 => { + 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); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 16eda4b3..1cc2dd08 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -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; + 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({ + 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, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c3be9fbf..fe5d6681 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -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", diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 2c7f2678..fb3e938d 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -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' }); }); diff --git a/app/game-frontend/src/stores/tournamentPages.ts b/app/game-frontend/src/stores/tournamentPages.ts index 18341c16..e0a00cee 100644 --- a/app/game-frontend/src/stores/tournamentPages.ts +++ b/app/game-frontend/src/stores/tournamentPages.ts @@ -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 | 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' }); diff --git a/app/game-frontend/src/utils/authenticatedEventSource.ts b/app/game-frontend/src/utils/authenticatedEventSource.ts new file mode 100644 index 00000000..fad692a3 --- /dev/null +++ b/app/game-frontend/src/utils/authenticatedEventSource.ts @@ -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', + }); + }, + }); +}; diff --git a/app/game-frontend/test/authenticatedEventSource.test.ts b/app/game-frontend/test/authenticatedEventSource.test.ts new file mode 100644 index 00000000..c23ee07c --- /dev/null +++ b/app/game-frontend/test/authenticatedEventSource.test.ts @@ -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 => + 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 => + 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((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((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')); +}); diff --git a/app/gateway-api/src/account/remoteUserIconStore.ts b/app/gateway-api/src/account/remoteUserIconStore.ts index 35ab1273..40e4bd58 100644 --- a/app/gateway-api/src/account/remoteUserIconStore.ts +++ b/app/gateway-api/src/account/remoteUserIconStore.ts @@ -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; } -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}` }; } } diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index 97cc398c..ecd67426 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -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() })) diff --git a/app/gateway-api/src/auth/attemptBudget.ts b/app/gateway-api/src/auth/attemptBudget.ts new file mode 100644 index 00000000..c88fe462 --- /dev/null +++ b/app/gateway-api/src/auth/attemptBudget.ts @@ -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; +} + +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; + }; + eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise; + } + ) {} + + get available(): boolean { + return this.client.isReady !== false; + } + + async consume(counters: AuthCounter[]): Promise { + 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(); + + async consume(counters: AuthCounter[]): Promise { + 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( + action: string, + client: string, + subject: string | undefined | (() => Promise), + work: () => Promise + ): Promise { + 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 => { + 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 | undefined; + let retryMs: number; + try { + retryMs = await Promise.race([ + checking, + new Promise((_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; + } + } +} diff --git a/app/gateway-api/src/auth/flushPublisher.ts b/app/gateway-api/src/auth/flushPublisher.ts index 86cce640..6e0679e0 100644 --- a/app/gateway-api/src/auth/flushPublisher.ts +++ b/app/gateway-api/src/auth/flushPublisher.ts @@ -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; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + 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 }; + private readonly client: RedisClientLike; + private readonly commands = new SessionRevocationCommandBudget(32); - constructor(client: { publish: (channel: string, message: string) => Promise }, 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 { + // 새 계약 최초 활성화에서만 기존 게임 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.'); } } diff --git a/app/gateway-api/src/auth/inMemorySessionService.ts b/app/gateway-api/src/auth/inMemorySessionService.ts index 62669df6..99e5a826 100644 --- a/app/gateway-api/src/auth/inMemorySessionService.ts +++ b/app/gateway-api/src/auth/inMemorySessionService.ts @@ -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 { 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(); + 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(); 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; } diff --git a/app/gateway-api/src/auth/kakaoClient.ts b/app/gateway-api/src/auth/kakaoClient.ts index 171071d0..4c1cffd6 100644 --- a/app/gateway-api/src/auth/kakaoClient.ts +++ b/app/gateway-api/src/auth/kakaoClient.ts @@ -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 }> { + 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 }; + } 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 { - 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; 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 { - 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; 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 { - 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; 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 { - 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; 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; return { @@ -156,7 +198,7 @@ export class KakaoOAuthClient { } async sendTalkMessage(accessToken: string, message: string, link: string): Promise { - 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; 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.'); } } } diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts index e2fe9ecb..f0e0aa37 100644 --- a/app/gateway-api/src/auth/redisSessionService.ts +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -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; -} - interface RedisClientLike { get(key: string): Promise; set(key: string, value: string, options?: { EX?: number }): Promise; - sMembers(key: string): Promise; - multi(): RedisPipeline; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; del(key: string): Promise; } +// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다. +// 기존 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 { 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 { @@ -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; } diff --git a/app/gateway-api/src/auth/sessionService.ts b/app/gateway-api/src/auth/sessionService.ts index 387514fa..bc36bbe2 100644 --- a/app/gateway-api/src/auth/sessionService.ts +++ b/app/gateway-api/src/auth/sessionService.ts @@ -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; diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 31f4356d..ff565812 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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', diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 6fc94805..abe2792b 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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; prisma: GatewayPrismaClient; adminAudit: AdminAuditStore; @@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: { releases?: GatewayReleaseRepository; orchestrator: GatewayOrchestratorHandle; profileStatus: GatewayProfileStatusService; + authBudget?: AuthAttemptBudget; + requestIp?: string; requestHeaders?: Record; 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), diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 1c63781d..5b276dc4 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -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 | 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 => + 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 { 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 | 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 { - let message: RemoteBuildMessage; + private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise { + 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); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 99bacbbc..30f7a5a5 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 6ec2f777..9038bb3b 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -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 = { diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 5e3a08cc..9fed5b50 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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, diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts index 315a247b..6dc912b0 100644 --- a/app/gateway-api/src/trpc.ts +++ b/app/gateway-api/src/trpc.ts @@ -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().create(); +const t = initTRPC.context().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) : {}; + const resolveSubject = async (): Promise => { + 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}초 후 다시 시도해 주세요.` + : '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.', + }); + } +}); diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts index 43ded139..7f1ddaf0 100644 --- a/app/gateway-api/src/webPush/coordinator.ts +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -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 diff --git a/app/gateway-api/src/webPush/sendNotification.ts b/app/gateway-api/src/webPush/sendNotification.ts new file mode 100644 index 00000000..036f37d4 --- /dev/null +++ b/app/gateway-api/src/webPush/sendNotification.ts @@ -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 => + 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 => { + parsePushEndpoint(subscription.endpoint); + const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 }); + await sendPushRequest(details); +}; diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 5ecd8c15..7bdcb9e6 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -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' }; diff --git a/app/gateway-api/test/authAttemptBudget.integration.test.ts b/app/gateway-api/test/authAttemptBudget.integration.test.ts new file mode 100644 index 00000000..76ea2239 --- /dev/null +++ b/app/gateway-api/test/authAttemptBudget.integration.test.ts @@ -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', + }); + }); +}); diff --git a/app/gateway-api/test/authAttemptBudget.test.ts b/app/gateway-api/test/authAttemptBudget.test.ts new file mode 100644 index 00000000..086ab981 --- /dev/null +++ b/app/gateway-api/test/authAttemptBudget.test.ts @@ -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((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((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(); + }); +}); diff --git a/app/gateway-api/test/authBudgetHttp.integration.test.ts b/app/gateway-api/test/authBudgetHttp.integration.test.ts new file mode 100644 index 00000000..f2315d12 --- /dev/null +++ b/app/gateway-api/test/authBudgetHttp.integration.test.ts @@ -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(); + 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 = {}) => + 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((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(); + } + }); +}); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 2c18ef32..91666fb8 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -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 { diff --git a/app/gateway-api/test/flushPublisher.test.ts b/app/gateway-api/test/flushPublisher.test.ts new file mode 100644 index 00000000..8bdc6149 --- /dev/null +++ b/app/gateway-api/test/flushPublisher.test.ts @@ -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; persisted: string | null } | undefined; + await subscriber.subscribe(channel, async (raw) => { + delivered = { + event: JSON.parse(raw) as Record, + 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'); + }); +}); diff --git a/app/gateway-api/test/gameSessionLifecycle.test.ts b/app/gateway-api/test/gameSessionLifecycle.test.ts new file mode 100644 index 00000000..c02e117c --- /dev/null +++ b/app/gateway-api/test/gameSessionLifecycle.test.ts @@ -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(); + } + }); +}); diff --git a/app/gateway-api/test/httpErrorPrivacy.test.ts b/app/gateway-api/test/httpErrorPrivacy.test.ts new file mode 100644 index 00000000..4d20f567 --- /dev/null +++ b/app/gateway-api/test/httpErrorPrivacy.test.ts @@ -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(); + } + }); +}); diff --git a/app/gateway-api/test/kakaoClient.test.ts b/app/gateway-api/test/kakaoClient.test.ts index 12b9b9d0..73c00215 100644 --- a/app/gateway-api/test/kakaoClient.test.ts +++ b/app/gateway-api/test/kakaoClient.test.ts @@ -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.'); }); }); diff --git a/app/gateway-api/test/kakaoTransportLimits.test.ts b/app/gateway-api/test/kakaoTransportLimits.test.ts new file mode 100644 index 00000000..01030c11 --- /dev/null +++ b/app/gateway-api/test/kakaoTransportLimits.test.ts @@ -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((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((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.'); + }); +}); diff --git a/app/gateway-api/test/remoteBuildRunner.test.ts b/app/gateway-api/test/remoteBuildRunner.test.ts index 131615a2..e897677a 100644 --- a/app/gateway-api/test/remoteBuildRunner.test.ts +++ b/app/gateway-api/test/remoteBuildRunner.test.ts @@ -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 }>; }; @@ -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', diff --git a/app/gateway-api/test/remoteBuildRunnerHttp.test.ts b/app/gateway-api/test/remoteBuildRunnerHttp.test.ts new file mode 100644 index 00000000..2313f56c --- /dev/null +++ b/app/gateway-api/test/remoteBuildRunnerHttp.test.ts @@ -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((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((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.' }); + }); +}); diff --git a/app/gateway-api/test/remoteUserIconStore.test.ts b/app/gateway-api/test/remoteUserIconStore.test.ts index bcb9b6ec..761b24ba 100644 --- a/app/gateway-api/test/remoteUserIconStore.test.ts +++ b/app/gateway-api/test/remoteUserIconStore.test.ts @@ -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(); + }); }); diff --git a/app/gateway-api/test/webPushCoordinator.integration.test.ts b/app/gateway-api/test/webPushCoordinator.integration.test.ts index 9aea58c3..9b0efe76 100644 --- a/app/gateway-api/test/webPushCoordinator.integration.test.ts +++ b/app/gateway-api/test/webPushCoordinator.integration.test.ts @@ -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( diff --git a/app/gateway-api/test/webPushTransport.test.ts b/app/gateway-api/test/webPushTransport.test.ts new file mode 100644 index 00000000..33e5d616 --- /dev/null +++ b/app/gateway-api/test/webPushTransport.test.ts @@ -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((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); +const transport = + (port: number): Parameters[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); + } + }); +}); diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index 7750dd9a..7139adb2 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -29,7 +29,12 @@ const main = async (): Promise => { 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, diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index f7f0f0d8..0fcf5da4 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -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 { - 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( diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 9dbb9804..f1cb1982 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -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, diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index 8d3b41f7..86e03291 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -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: diff --git a/docs/release-operations.md b/docs/release-operations.md index 59b4e4b0..fc646075 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -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 성공 검증을 대신하지 않는다. diff --git a/docs/tournament-authorization.md b/docs/tournament-authorization.md new file mode 100644 index 00000000..1ba29ed7 --- /dev/null +++ b/docs/tournament-authorization.md @@ -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의 한계는 유지됩니다. diff --git a/packages/common/package.json b/packages/common/package.json index 66c19d24..40ad6f04 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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": { diff --git a/packages/common/src/auth/sessionRevocation.ts b/packages/common/src/auth/sessionRevocation.ts new file mode 100644 index 00000000..830f2218 --- /dev/null +++ b/packages/common/src/auth/sessionRevocation.ts @@ -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(start: () => Promise): Promise { + if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.'); + this.pending += 1; + let command: Promise; + 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 | undefined; + try { + return await Promise.race([ + command, + new Promise((_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); + } + } +} diff --git a/packages/common/src/http/apiOrigin.ts b/packages/common/src/http/apiOrigin.ts new file mode 100644 index 00000000..5280dc62 --- /dev/null +++ b/packages/common/src/http/apiOrigin.ts @@ -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(); + 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 => { + 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(); + } + }; +}; diff --git a/packages/common/src/http/errorPrivacy.ts b/packages/common/src/http/errorPrivacy.ts new file mode 100644 index 00000000..50b091bc --- /dev/null +++ b/packages/common/src/http/errorPrivacy.ts @@ -0,0 +1,31 @@ +/** Preserve public validation/permission messages while hiding internal causes and stacks. */ +export const safeTrpcErrorFormatter = ({ + 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'), + }); +}; diff --git a/packages/common/src/http/safeLogger.ts b/packages/common/src/http/safeLogger.ts new file mode 100644 index 00000000..6b9ea512 --- /dev/null +++ b/packages/common/src/http/safeLogger.ts @@ -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) : {}; + 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 + ) + ); + } + }, + }, +}; diff --git a/packages/common/src/images/imageUpload.ts b/packages/common/src/images/imageUpload.ts new file mode 100644 index 00000000..242d221b --- /dev/null +++ b/packages/common/src/images/imageUpload.ts @@ -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(work: () => Promise): Promise { + 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 => { + 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 | 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((_, 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); + } +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 6a2e9d0f..2dd93f49 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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'; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 4f052cd1..aada6d63 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -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; diff --git a/packages/common/test/apiOrigin.test.ts b/packages/common/test/apiOrigin.test.ts new file mode 100644 index 00000000..021da1a5 --- /dev/null +++ b/packages/common/test/apiOrigin.test.ts @@ -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 }); + }); +}); diff --git a/packages/common/test/imageUpload.test.ts b/packages/common/test/imageUpload.test.ts new file mode 100644 index 00000000..ab05909b --- /dev/null +++ b/packages/common/test/imageUpload.test.ts @@ -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((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({ + 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((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((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); + } + } + ); +}); diff --git a/packages/common/test/session-revocation.test.ts b/packages/common/test/session-revocation.test.ts new file mode 100644 index 00000000..0a78edfe --- /dev/null +++ b/packages/common/test/session-revocation.test.ts @@ -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((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(); + }); +}); diff --git a/packages/common/tsdown.config.ts b/packages/common/tsdown.config.ts index 2e65e5c1..bfce4e0f 100644 --- a/packages/common/tsdown.config.ts +++ b/packages/common/tsdown.config.ts @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35caac9a..42185013 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}