운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
parseSessionRevocationWatermark,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
getFlushedAt(userId: string): Date | null;
|
||||
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
|
||||
applyFlush(event: GatewayUserFlushEvent): void;
|
||||
}
|
||||
|
||||
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
|
||||
}
|
||||
}
|
||||
|
||||
interface RevocationRedisClient {
|
||||
readonly isReady?: boolean;
|
||||
mGet(keys: string[]): Promise<(string | null)[]>;
|
||||
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
|
||||
}
|
||||
|
||||
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
|
||||
export class RedisFlushStore implements FlushStore {
|
||||
private readonly local = new Map<string, Date>();
|
||||
private readonly commands = new SessionRevocationCommandBudget(64);
|
||||
constructor(
|
||||
private readonly client: RevocationRedisClient,
|
||||
private readonly channel: string
|
||||
) {}
|
||||
|
||||
private commandClient(): RevocationRedisClient {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async assertInitialized(): Promise<void> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
|
||||
);
|
||||
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
|
||||
throw new Error('Gateway session revocation must be initialized before game API startup.');
|
||||
}
|
||||
|
||||
async getFlushedAt(userId: string): Promise<Date | null> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
])
|
||||
);
|
||||
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
|
||||
const baseline = parseSessionRevocationWatermark(values[0]!);
|
||||
if (!baseline) throw new Error('Session revocation storage is unavailable.');
|
||||
const persisted = parseSessionRevocationWatermark(values[1]!);
|
||||
return new Date(
|
||||
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
applyFlush(event: GatewayUserFlushEvent): void {
|
||||
const parsed = new Date(event.flushedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return;
|
||||
const prior = this.local.get(event.userId);
|
||||
if (!prior || parsed > prior) {
|
||||
this.local.delete(event.userId);
|
||||
this.local.set(event.userId, parsed);
|
||||
}
|
||||
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
|
||||
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushSubscriber {
|
||||
private readonly client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
corsAllowedOrigins: string[];
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||
env.GATEWAY_PUBLIC_URL,
|
||||
env.GAME_PUBLIC_URL,
|
||||
]),
|
||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const MAX_PENDING_FRAMES = 32;
|
||||
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
/** Revalidate before work and before writing its result; never retain unbounded frames. */
|
||||
export class AuthenticatedRealtimeConnection {
|
||||
private queue = Promise.resolve();
|
||||
private pending = 0;
|
||||
private ended = false;
|
||||
private readonly expiryTimer: ReturnType<typeof setTimeout>;
|
||||
private cleanup = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
readonly auth: GameSessionTokenPayload,
|
||||
private readonly response: ServerResponse,
|
||||
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
|
||||
) {
|
||||
// AccessTokenStore requires a full remaining TTL second on every lookup.
|
||||
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
|
||||
this.expiryTimer.unref();
|
||||
response.once('close', this.close);
|
||||
response.once('error', this.close);
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.ended;
|
||||
}
|
||||
|
||||
whenIdle(): Promise<void> {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
onClose(cleanup: () => void): void {
|
||||
if (this.ended) cleanup();
|
||||
else this.cleanup.add(cleanup);
|
||||
}
|
||||
|
||||
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
|
||||
if (this.ended) return null;
|
||||
const current = await this.resolveAuth();
|
||||
if (
|
||||
!current ||
|
||||
this.ended ||
|
||||
current.sessionId !== this.auth.sessionId ||
|
||||
current.user.id !== this.auth.user.id ||
|
||||
current.profile !== this.auth.profile
|
||||
) {
|
||||
this.close();
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
|
||||
if (this.ended) return;
|
||||
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.pending += 1;
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
const current = await this.currentAuth();
|
||||
if (!current) return;
|
||||
const frame = await work(current);
|
||||
if (!frame || !(await this.currentAuth())) return;
|
||||
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
|
||||
})
|
||||
.catch(() => {
|
||||
// Redis/auth failure is fail closed. Reconnect must authenticate again.
|
||||
this.close();
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
close = (): void => {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.response.off('close', this.close);
|
||||
this.response.off('error', this.close);
|
||||
for (const cleanup of this.cleanup) cleanup();
|
||||
this.cleanup.clear();
|
||||
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
|
||||
const OPENING_DEADLINE_MS = 5000;
|
||||
|
||||
export interface RealtimeConnectionReservation {
|
||||
canContinue(): boolean;
|
||||
reserveUser(userId: string): boolean;
|
||||
startStreaming(): boolean;
|
||||
cancelOpening(): void;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** One game API process owns both pending setup and live streams; no waiting queue. */
|
||||
export class RealtimeConnectionAdmission {
|
||||
private readonly limits: { connections: number; perUser: number; opening: number };
|
||||
private total = 0;
|
||||
private opening = 0;
|
||||
private closed = false;
|
||||
private readonly byUser = new Map<string, number>();
|
||||
private readonly pending = new Set<() => void>();
|
||||
|
||||
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||
throw new Error('Invalid realtime connection limits.');
|
||||
}
|
||||
|
||||
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
|
||||
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
|
||||
this.total += 1;
|
||||
this.opening += 1;
|
||||
let userId: string | null = null;
|
||||
let streaming = false;
|
||||
let cancelled = false;
|
||||
let released = false;
|
||||
const deadline = Date.now() + OPENING_DEADLINE_MS;
|
||||
const expire = () => {
|
||||
if (released || streaming || cancelled) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
onTimeout();
|
||||
};
|
||||
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
|
||||
timer.unref();
|
||||
this.pending.add(expire);
|
||||
const canContinue = () => {
|
||||
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
|
||||
return !released && !streaming && !cancelled;
|
||||
};
|
||||
return {
|
||||
canContinue,
|
||||
reserveUser: (id) => {
|
||||
if (!canContinue() || userId !== null) return false;
|
||||
const count = this.byUser.get(id) ?? 0;
|
||||
if (count >= this.limits.perUser) return false;
|
||||
this.byUser.set(id, count + 1);
|
||||
userId = id;
|
||||
return true;
|
||||
},
|
||||
startStreaming: () => {
|
||||
if (!canContinue() || userId === null) return false;
|
||||
streaming = true;
|
||||
this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
return true;
|
||||
},
|
||||
cancelOpening: () => {
|
||||
if (released || streaming) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.total -= 1;
|
||||
if (!streaming) this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
if (userId !== null) {
|
||||
const count = (this.byUser.get(userId) ?? 1) - 1;
|
||||
if (count === 0) this.byUser.delete(userId);
|
||||
else this.byUser.set(userId, count);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const expire of this.pending) expire();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ImageWorkBudget,
|
||||
ImageWorkBusyError,
|
||||
ImageUploadError,
|
||||
isRasterImage,
|
||||
} from '@sammo-ts/common/images/imageUpload';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'crypto';
|
||||
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const boardImageBudget = new ImageWorkBudget();
|
||||
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
||||
const MAX_LONG_EDGE = 2048;
|
||||
const WEBP_QUALITY = 80;
|
||||
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
|
||||
buffer: Buffer,
|
||||
{ animated, resize }: { animated: boolean; resize: boolean }
|
||||
): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
|
||||
...(animated ? { animated: true } : {}),
|
||||
};
|
||||
|
||||
return pipeline.webp(webpOptions).toBuffer();
|
||||
return pipeline
|
||||
.webp(webpOptions)
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
|
||||
return pipeline
|
||||
.avif({ quality: 60, effort: 4 })
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
export const boardRouter = router({
|
||||
@@ -213,70 +232,116 @@ export const boardRouter = router({
|
||||
|
||||
return { id: comment.id };
|
||||
}),
|
||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
uploadImage: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dataUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
||||
}
|
||||
try {
|
||||
return await boardImageBudget.run(async () => {
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: 'PAYLOAD_TOO_LARGE',
|
||||
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (
|
||||
format === 'webp' &&
|
||||
!needsResize &&
|
||||
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||
) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.contentImageUpload) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||
const uploaded = await ctx.contentImageUpload.upload({
|
||||
filename,
|
||||
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
|
||||
body: outputBuffer,
|
||||
});
|
||||
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.contentImageUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
}
|
||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||
const uploaded = await ctx.contentImageUpload.upload({
|
||||
filename,
|
||||
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
|
||||
body: outputBuffer,
|
||||
});
|
||||
|
||||
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type === 'commandRejected') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
}
|
||||
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||
}
|
||||
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
return true;
|
||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
||||
|
||||
const zBetEntry = z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (entry.generalId === general.id) {
|
||||
if (entry.userId === ctx.auth!.user.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -develCost,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
assertTournamentAdjustment(feeResult);
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: develCost,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||
if (entries.some((entry) => entry.userId === undefined)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -input.amount,
|
||||
betGoldDelta: input.amount,
|
||||
minGoldAfter: 500,
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
assertTournamentAdjustment(adjustResult);
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
userId: ctx.auth!.user.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: input.amount,
|
||||
betGoldDelta: -input.amount,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
+184
-108
@@ -1,8 +1,10 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -21,7 +23,7 @@ import {
|
||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||
import {
|
||||
consumeRealtimeAccessGrantHeader,
|
||||
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
|
||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
|
||||
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
import {
|
||||
shouldForwardRealtimeEvent,
|
||||
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
flushStore: FlushStore,
|
||||
profileName: string
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
if (!stored || stored.profile !== profileName) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||
let flushedAt: Date | null;
|
||||
try {
|
||||
flushedAt = await flushStore.getFlushedAt(stored.user.id);
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
|
||||
}
|
||||
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
|
||||
imageUploadSecret
|
||||
);
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
logger: safeHttpLoggerOptions,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
|
||||
const realtimeAdmission = new RealtimeConnectionAdmission();
|
||||
app.addHook('preClose', async () => {
|
||||
realtimeAdmission.close();
|
||||
for (const connection of realtimeConnections) connection.close();
|
||||
});
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
|
||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||
});
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
|
||||
try {
|
||||
await flushStore.assertInitialized();
|
||||
} catch {
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (redis.client.isOpen) redis.client.destroy();
|
||||
}),
|
||||
postgres.disconnect(),
|
||||
]);
|
||||
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
|
||||
}
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
try {
|
||||
await flushSubscriberClient.connect();
|
||||
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
|
||||
config.flushChannel,
|
||||
flushStore,
|
||||
async (event) => {
|
||||
const flushedAt = Date.parse(event.flushedAt);
|
||||
for (const connection of realtimeConnections) {
|
||||
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
|
||||
},
|
||||
(error, event) => {
|
||||
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
@@ -299,80 +335,124 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
|
||||
)
|
||||
return null;
|
||||
return auth;
|
||||
};
|
||||
|
||||
app.get(config.eventsPath, async (request, reply) => {
|
||||
const query = request.query as { token?: string; scope?: string };
|
||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||
|
||||
if (!auth) {
|
||||
if (query.token !== undefined || !tokenFromHeader) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
let expired = false;
|
||||
let disconnected = false;
|
||||
const opening = realtimeAdmission.begin(() => {
|
||||
expired = true;
|
||||
if (!reply.sent && !reply.raw.destroyed) {
|
||||
reply.header('Retry-After', '15');
|
||||
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
});
|
||||
if (!opening) {
|
||||
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
return;
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
const cancelOpening = () => {
|
||||
disconnected = true;
|
||||
opening.cancelOpening();
|
||||
};
|
||||
request.raw.once('aborted', cancelOpening);
|
||||
reply.raw.once('close', cancelOpening);
|
||||
let transferred = false;
|
||||
try {
|
||||
let auth: GameSessionTokenPayload | null = null;
|
||||
try {
|
||||
reply.raw.write(payload);
|
||||
auth = await resolveRealtimeAuth(tokenFromHeader);
|
||||
} catch {
|
||||
if (opening.canContinue() && !reply.raw.destroyed)
|
||||
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
if (!opening.canContinue() || reply.raw.destroyed) return;
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: '{}',
|
||||
})
|
||||
);
|
||||
if (!auth || auth.profile !== config.profileName) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
if (!opening.reserveUser(auth.user.id)) {
|
||||
if (opening.canContinue())
|
||||
await reply
|
||||
.header('Retry-After', '15')
|
||||
.status(429)
|
||||
.send({ ok: false, error: 'realtime_connection_limit' });
|
||||
return;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||
resolveRealtimeAuth(tokenFromHeader)
|
||||
);
|
||||
realtimeConnections.add(connection);
|
||||
connection.onClose(() => {
|
||||
realtimeConnections.delete(connection);
|
||||
// A disconnected socket can still own an in-flight Redis/SQL operation.
|
||||
void connection.whenIdle().then(opening.release, opening.release);
|
||||
});
|
||||
transferred = true;
|
||||
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
connection.enqueue(async (currentAuth) => {
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
@@ -381,58 +461,54 @@ export const createGameApiServer = async () => {
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
||||
refreshGrant ??= createRealtimeAccessGrant(
|
||||
currentAuth,
|
||||
config.profileName,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || closed) return;
|
||||
if (!publicEvent || connection.closed) return null;
|
||||
if (refreshGrant) {
|
||||
try {
|
||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||
} catch {
|
||||
// Preserve the invalidation. An unregistered grant safely falls back
|
||||
// to the normal scored refresh path.
|
||||
// An unregistered grant falls back to the scored refresh path.
|
||||
}
|
||||
}
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: publicEvent.type,
|
||||
data: JSON.stringify(publicEvent),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A best-effort notification must not affect committed game state.
|
||||
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||
});
|
||||
});
|
||||
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
});
|
||||
connection.onClose(unsubscribe);
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending || connection.closed) return;
|
||||
heartbeatPending = true;
|
||||
connection.enqueue(async () => {
|
||||
try {
|
||||
return formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatPending = false;
|
||||
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||
});
|
||||
} finally {
|
||||
heartbeatPending = false;
|
||||
}
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
}, 15_000);
|
||||
connection.onClose(() => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
request.raw.once('aborted', connection.close);
|
||||
connection.onClose(() => {
|
||||
request.raw.off('aborted', connection.close);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!expired && !disconnected) throw error;
|
||||
} finally {
|
||||
request.raw.off('aborted', cancelOpening);
|
||||
reply.raw.off('close', cancelOpening);
|
||||
if (!transferred) opening.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface ContentImageUploadResult {
|
||||
publicUrl: string;
|
||||
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid content image filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const digest = createHash('sha256').update(input.body).digest('hex');
|
||||
const signature = createHmac('sha256', this.secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature,
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'content',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
const expectedPath = `uploads/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
|
||||
throw new Error('Image repository returned an unexpected content path.');
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
(candidate) =>
|
||||
candidate.generalId === entry.generalId &&
|
||||
candidate.targetId === entry.targetId &&
|
||||
candidate.userId === entry.userId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
||||
|
||||
export interface TournamentBetEntry {
|
||||
generalId: number;
|
||||
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||
userId?: string | null;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
|
||||
@@ -20,10 +21,10 @@ import {
|
||||
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
||||
import { recordGeneralActivity } from './services/generalActivity.js';
|
||||
|
||||
const t = initTRPC.context<GameApiContext>().create();
|
||||
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
const payload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
sessionId: 'test-session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'test-user',
|
||||
username: 'test',
|
||||
displayName: 'test',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
|
||||
const response = new PassThrough();
|
||||
const frames: string[] = [];
|
||||
response.on('data', (chunk) => frames.push(String(chunk)));
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
|
||||
return { response, frames, connection };
|
||||
};
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('authenticated realtime connection', () => {
|
||||
it('does not forward an in-flight result after the token is revoked during its work', async () => {
|
||||
const auth = payload();
|
||||
let current: GameSessionTokenPayload | null = auth;
|
||||
const { connection, frames } = fixture(async () => current, auth);
|
||||
connection.enqueue(async () => {
|
||||
current = null;
|
||||
return 'private invalidation';
|
||||
});
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('fails closed when authentication storage fails', async () => {
|
||||
const { connection, frames } = fixture(async () => {
|
||||
throw new Error('redis unavailable');
|
||||
});
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends an idle expired stream and releases subscriptions only once', async () => {
|
||||
vi.useFakeTimers();
|
||||
const auth = payload();
|
||||
const { connection } = fixture(async () => auth, auth);
|
||||
const cleanup = vi.fn();
|
||||
connection.onClose(cleanup);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
expect(connection.closed).toBe(true);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
connection.close();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('bounds queued work while an earlier event is blocked', async () => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => auth, auth);
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
connection.enqueue(async () => {
|
||||
await waiting;
|
||||
return 'private invalidation';
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
|
||||
expect(connection.closed).toBe(true);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends a slow socket instead of retaining further frames', async () => {
|
||||
const auth = payload();
|
||||
const { connection, response } = fixture(async () => auth, auth);
|
||||
response.write = vi.fn(() => false);
|
||||
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(response.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
|
||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||
});
|
||||
|
||||
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(png.length).toBeLessThan(1024 * 1024);
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
|
||||
const pending: Array<() => void> = [];
|
||||
const upload = vi.fn(
|
||||
() =>
|
||||
new Promise<{ publicUrl: string }>((resolve) => {
|
||||
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
|
||||
})
|
||||
);
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
|
||||
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
|
||||
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
} finally {
|
||||
pending.forEach((done) => done());
|
||||
await Promise.all(admitted);
|
||||
}
|
||||
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
|
||||
});
|
||||
|
||||
it('rejects SVG before a native image parser or upload is used', async () => {
|
||||
const upload = vi.fn();
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const makeGif = (pages: number) =>
|
||||
sharp(Buffer.alloc(pages * 4, 255), {
|
||||
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
|
||||
})
|
||||
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
|
||||
.toBuffer();
|
||||
const excessive = await makeGif(257);
|
||||
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
|
||||
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
const ordinary = await makeGif(3);
|
||||
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
|
||||
animated: true,
|
||||
format: 'webp',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
|
||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||
|
||||
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
|
||||
|
||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) return;
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
||||
if (keys.length > 0) await redis.client.del(keys);
|
||||
}
|
||||
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
|
||||
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createServer, type Socket } from 'node:net';
|
||||
import { createClient } from 'redis';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
|
||||
describe('RedisGatewayFlushSubscriber', () => {
|
||||
it('reports asynchronous flush handler failures with event context', async () => {
|
||||
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisFlushStore authorization state', () => {
|
||||
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
|
||||
let persisted: string | null = null;
|
||||
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
|
||||
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
|
||||
await store.assertInitialized();
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
|
||||
persisted = '200';
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
|
||||
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
|
||||
expect(mGet).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it('retains revocation after local cache eviction and a fresh store instance', async () => {
|
||||
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
|
||||
const store = new RedisFlushStore(client, 'fixture:flush');
|
||||
for (let i = 0; i < 4100; i++)
|
||||
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
|
||||
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
});
|
||||
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
|
||||
'rejects missing or corrupt initialization %s',
|
||||
async (baseline) => {
|
||||
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
|
||||
await expect(store.assertInitialized()).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
|
||||
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
|
||||
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
|
||||
});
|
||||
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
|
||||
const offlineGet = vi.fn(async () => ['0', null]);
|
||||
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
|
||||
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
|
||||
expect(offlineGet).not.toHaveBeenCalled();
|
||||
const mGet = vi.fn(async () => ['0', '100']);
|
||||
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
|
||||
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
|
||||
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
|
||||
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
|
||||
const sockets = new Set<Socket>();
|
||||
let receivedRead = false;
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('data', (data) => {
|
||||
const request = data.toString();
|
||||
// Real node-redis handshake succeeds; its MGET command receives no reply.
|
||||
if (request.includes('MGET')) {
|
||||
receivedRead = true;
|
||||
return;
|
||||
}
|
||||
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
|
||||
socket.write('+OK\r\n'.repeat(count));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
|
||||
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
|
||||
try {
|
||||
await client.connect();
|
||||
expect(client.isReady).toBe(true);
|
||||
const start = Date.now();
|
||||
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
|
||||
expect(receivedRead).toBe(true);
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
|
||||
expect(Date.now() - start).toBeLessThan(4500);
|
||||
} finally {
|
||||
if (client.isOpen) client.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
||||
auth: token,
|
||||
db: { $executeRaw: executeRaw },
|
||||
generalAccessTracking: true,
|
||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
const activityRouter = router({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fastify from 'fastify';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
|
||||
|
||||
const internalFailure = () => {
|
||||
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||
};
|
||||
const badInput = () => {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '잘못된 입력입니다.',
|
||||
cause: new Error('synthetic-private-cause'),
|
||||
});
|
||||
};
|
||||
|
||||
describe('public HTTP error privacy', () => {
|
||||
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/game',
|
||||
trpcOptions: {
|
||||
router: gameRouter({
|
||||
internal: sessionActivityProcedure.query(internalFailure),
|
||||
invalid: sessionActivityProcedure.query(badInput),
|
||||
}),
|
||||
createContext: () => ({}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
for (const prefix of ['game']) {
|
||||
for (const procedure of ['internal', 'invalid']) {
|
||||
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||
const body = (await response.json()) as {
|
||||
error: { message: string; data: { code: string; stack?: string } };
|
||||
};
|
||||
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||
expect(body.error.message).toBe(
|
||||
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||
);
|
||||
expect(body.error.data.stack).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const buildContext = (
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
|
||||
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: `sammo:game:*:${profileName}:*`,
|
||||
COUNT: 100,
|
||||
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
|
||||
}
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: selectedProfile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('realtime connection admission', () => {
|
||||
it('reserves the process total before authentication and counts live streams too', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
const second = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('actor')).toBe(true);
|
||||
expect(first.startStreaming()).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
second.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement).not.toBeNull();
|
||||
replacement.release();
|
||||
first.release();
|
||||
});
|
||||
|
||||
it('shares the authenticated actor limit while allowing another actor', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('one')).toBe(true);
|
||||
const rejected = budget.begin(vi.fn())!;
|
||||
expect(rejected.reserveUser('one')).toBe(false);
|
||||
expect(rejected.startStreaming()).toBe(false);
|
||||
rejected.release();
|
||||
const other = budget.begin(vi.fn())!;
|
||||
expect(other.reserveUser('two')).toBe(true);
|
||||
first.release();
|
||||
first.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('one')).toBe(true);
|
||||
other.release();
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
|
||||
const timeout = vi.fn();
|
||||
const stalled = budget.begin(timeout)!;
|
||||
const disconnected = budget.begin(vi.fn())!;
|
||||
expect(stalled.reserveUser('stalled')).toBe(true);
|
||||
disconnected.cancelOpening();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(stalled.canContinue()).toBe(false);
|
||||
expect(stalled.startStreaming()).toBe(false);
|
||||
expect(disconnected.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
stalled.release();
|
||||
const recovered = budget.begin(vi.fn())!;
|
||||
expect(recovered.reserveUser('stalled')).toBe(true);
|
||||
expect(recovered.startStreaming()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
disconnected.release();
|
||||
recovered.release();
|
||||
});
|
||||
|
||||
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const timeout = vi.fn();
|
||||
const pending = budget.begin(timeout)!;
|
||||
budget.close();
|
||||
budget.close();
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(pending.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
pending.release();
|
||||
});
|
||||
|
||||
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser('actor')).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const inFlight = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
sessionId: 'session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'actor',
|
||||
username: 'actor',
|
||||
displayName: 'actor',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const response = new PassThrough();
|
||||
const connection = new AuthenticatedRealtimeConnection(
|
||||
auth,
|
||||
response as unknown as ServerResponse,
|
||||
async () => {
|
||||
started();
|
||||
await inFlight;
|
||||
return auth;
|
||||
}
|
||||
);
|
||||
connection.onClose(() => {
|
||||
void connection.whenIdle().then(slot.release, slot.release);
|
||||
});
|
||||
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
|
||||
await running;
|
||||
connection.close();
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
finish();
|
||||
await connection.whenIdle();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('actor')).toBe(true);
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
|
||||
expect(pending.every(Boolean)).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
for (const slot of pending) slot.release();
|
||||
const live = Array.from({ length: 512 }, (_, index) => {
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
return slot;
|
||||
});
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
live[0]!.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('replacement')).toBe(true);
|
||||
expect(replacement.startStreaming()).toBe(true);
|
||||
for (const slot of live) slot.release();
|
||||
replacement.release();
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
|
||||
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
||||
expect(Object.values(headers)).not.toContain(secret);
|
||||
});
|
||||
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||
const filename = `${'d'.repeat(32)}.png`;
|
||||
const store = new RemoteContentImageStore(
|
||||
'https://image.example',
|
||||
'https://image.example/public',
|
||||
'synthetic-secret',
|
||||
async () =>
|
||||
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||
);
|
||||
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Writable } from 'node:stream';
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
|
||||
describe('HTTP log credential boundaries', () => {
|
||||
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
|
||||
let output = '';
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
output += String(chunk);
|
||||
done();
|
||||
},
|
||||
});
|
||||
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
app.post('/events', async () => {
|
||||
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
|
||||
code: 'P2002',
|
||||
detail: 'synthetic-detail-secret',
|
||||
});
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'synthetic-body-secret' }),
|
||||
});
|
||||
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
|
||||
await fetch(`${address}/synthetic-path-secret`);
|
||||
const logs = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
|
||||
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
|
||||
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
|
||||
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
|
||||
expect(output).not.toContain(`synthetic-${field}-secret`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
@@ -68,6 +70,7 @@ const envKeys = [
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
'API_ALLOWED_ORIGINS',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: pattern,
|
||||
COUNT: 100,
|
||||
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
|
||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||
if (!redis) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||
for (const key of batch) keys.add(key);
|
||||
}
|
||||
@@ -640,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
|
||||
await redis.connect();
|
||||
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({
|
||||
host: server.config.host,
|
||||
@@ -818,6 +824,28 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
|
||||
const input = { dataUrl: 'data:image/png;base64,AA==' };
|
||||
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
|
||||
expect(unauthenticated.response.status).toBe(401);
|
||||
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
|
||||
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
|
||||
expect(denied.response.status).toBe(403);
|
||||
const officer = await createAccessToken('image-officer', {});
|
||||
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
|
||||
expect(malformed.response.status).toBe(400);
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const oversized = await requestTrpc('board.uploadImage', {
|
||||
method: 'POST',
|
||||
accessToken: officer,
|
||||
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
|
||||
});
|
||||
expect(oversized.response.status).toBe(400);
|
||||
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
|
||||
});
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
ordinal: 0,
|
||||
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
|
||||
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} }
|
||||
: ({ ...step, sequence })),
|
||||
steps: Array.from({ length: 128 }, (_, sequence) =>
|
||||
sequence === 127
|
||||
? {
|
||||
...step,
|
||||
sequence,
|
||||
kind: 'DECISION_START',
|
||||
reservedAction: '휴식',
|
||||
effectivePolicy: {
|
||||
schemaVersion: 1,
|
||||
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
|
||||
nation: {
|
||||
priority: ['천도'],
|
||||
flags: { 천도: true },
|
||||
values: {
|
||||
reqNationGold: 4321,
|
||||
reqNationRice: 100,
|
||||
reqHumanWarUrgentGold: 100,
|
||||
reqHumanWarUrgentRice: 100,
|
||||
reqHumanWarRecommandGold: 100,
|
||||
reqHumanWarRecommandRice: 100,
|
||||
reqHumanDevelGold: 100,
|
||||
reqHumanDevelRice: 100,
|
||||
reqNpcWarGold: 100,
|
||||
reqNpcWarRice: 100,
|
||||
reqNpcDevelGold: 100,
|
||||
reqNpcDevelRice: 100,
|
||||
minimumResourceActionAmount: 100,
|
||||
maximumResourceActionAmount: 100,
|
||||
minNpcWarLeadership: 100,
|
||||
minWarCrew: 100,
|
||||
minNpcRecruitCityPopulation: 100,
|
||||
safeRecruitCityPopulationRatio: 100,
|
||||
properWarTrainAtmos: 100,
|
||||
cureThreshold: 100,
|
||||
},
|
||||
combatForce: { '1': [2, 3] },
|
||||
supportForce: [4],
|
||||
developForce: [5],
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
}
|
||||
: { ...step, sequence }
|
||||
),
|
||||
},
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
@@ -3530,6 +3600,414 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const openRealtime = async (accessToken: string) => {
|
||||
const abort = new AbortController();
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
signal: abort.signal,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader();
|
||||
const ready = await reader.read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
return { abort, reader };
|
||||
};
|
||||
|
||||
it('SSE limits simultaneous openings for one authenticated user', async () => {
|
||||
const actor = `security-http-sse-concurrent-${process.pid}`;
|
||||
const token = await createAccessToken('sse-concurrent', {}, actor);
|
||||
const aborts = Array.from({ length: 10 }, () => new AbortController());
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
aborts.map((abort) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: abort.signal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
|
||||
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
|
||||
for (const response of responses.filter((item) => item.status === 429)) {
|
||||
expect(response.headers.get('retry-after')).toBe('15');
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
}
|
||||
} finally {
|
||||
for (const abort of aborts) abort.abort();
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
|
||||
const actor = `security-http-sse-shared-${process.pid}`;
|
||||
const token = await createAccessToken('sse-shared-a', {}, actor);
|
||||
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
|
||||
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
|
||||
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
|
||||
const replacementAbort = new AbortController();
|
||||
try {
|
||||
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
|
||||
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
});
|
||||
expect(rejected.status).toBe(429);
|
||||
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
streams.push(await openRealtime(otherActorToken));
|
||||
await streams[0]!.reader.cancel();
|
||||
streams[0]!.abort.abort();
|
||||
let replacement: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
signal: replacementAbort.signal,
|
||||
});
|
||||
if (response.status === 200) replacement = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const ready = await replacement!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
} finally {
|
||||
replacementAbort.abort();
|
||||
for (const stream of streams) stream.abort.abort();
|
||||
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
|
||||
const actor = `security-http-sse-pending-${process.pid}`;
|
||||
const tokens = await Promise.all(
|
||||
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
|
||||
);
|
||||
const aborts = Array.from({ length: 33 }, () => new AbortController());
|
||||
let unlock!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
unlock = resolve;
|
||||
});
|
||||
let ready!: (tx: GamePrisma.TransactionClient) => void;
|
||||
let lockError!: (error: unknown) => void;
|
||||
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
|
||||
ready = resolve;
|
||||
lockError = reject;
|
||||
});
|
||||
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
|
||||
const locking = db.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
|
||||
ready(tx);
|
||||
await release;
|
||||
},
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
void locking.catch(lockError);
|
||||
try {
|
||||
const tx = await locked;
|
||||
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[0]!}` },
|
||||
signal: aborts[0]!.signal,
|
||||
}).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const rows = await tx.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT COUNT(*)::int AS count FROM pg_locks
|
||||
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
|
||||
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
`;
|
||||
return rows[0]!.count;
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const startedAt = Date.now();
|
||||
const waiting = tokens.slice(1, 32).map((token, index) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: aborts[index + 1]!.signal,
|
||||
})
|
||||
);
|
||||
aborts[0]!.abort();
|
||||
expect(await first).toBe(true);
|
||||
const responses = await Promise.all(waiting);
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
|
||||
expect(Date.now() - startedAt).toBeLessThan(8500);
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const refusedAt = Date.now();
|
||||
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
expect(refused.status).toBe(503);
|
||||
expect(refused.headers.get('retry-after')).toBe('15');
|
||||
expect(Date.now() - refusedAt).toBeLessThan(2000);
|
||||
await refused.arrayBuffer();
|
||||
unlock();
|
||||
await locking;
|
||||
let recovered: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
if (response.status === 200) recovered = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const frame = await recovered!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
|
||||
} finally {
|
||||
unlock();
|
||||
for (const abort of aborts) abort.abort();
|
||||
await locking;
|
||||
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||
const token = await createAccessToken('origin-mutation', {});
|
||||
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
origin: 'https://untrusted.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||
origin === 'https://sam.hided.net' ? origin : null
|
||||
);
|
||||
}
|
||||
await accessTokenStore.revoke(token);
|
||||
});
|
||||
|
||||
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||
const foreign = await accessTokenStore.create({
|
||||
...buildPayload('trpc-foreign', {}),
|
||||
profile: 'other:default',
|
||||
});
|
||||
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||
const before = await readReservedMutationState();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await readReservedMutationState()).toEqual(before);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
expect(queryOnly.status).toBe(401);
|
||||
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
|
||||
if (!foreign) throw new Error('SSE foreign token fixture failed');
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const stream = await openRealtime(token);
|
||||
stream.abort.abort();
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{
|
||||
label: 'profile game restriction',
|
||||
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
|
||||
},
|
||||
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
|
||||
const token = await createAccessToken(`sse-${label}`, sanctions);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes on idle expiry without forwarding another frame', async () => {
|
||||
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
|
||||
const created = await accessTokenStore.create(auth);
|
||||
if (!created) throw new Error('SSE expiry token fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
|
||||
const accessToken = await createAccessToken('sse-revoke', {});
|
||||
const stream = await openRealtime(accessToken);
|
||||
const channel = server!.config.profileName;
|
||||
try {
|
||||
const event = JSON.stringify({ type: 'tournamentChanged' });
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
const valid = await stream.reader.read();
|
||||
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
|
||||
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
|
||||
const sseUser = `security-http-sse-flush-${process.pid}`;
|
||||
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
|
||||
if (!created) throw new Error('SSE flush fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
|
||||
const actor = `security-http-durable-missed-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missed', {}, actor);
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('retains persisted revocation state across an actual API restart', async () => {
|
||||
const actor = `security-http-durable-restart-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-restart', {}, actor);
|
||||
try {
|
||||
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await server!.app.close();
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
|
||||
method: 'POST',
|
||||
input: { gatewayToken },
|
||||
});
|
||||
expect(exchanged.response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
|
||||
const actor = `security-http-durable-sse-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-sse', {}, actor);
|
||||
const stream = await openRealtime(accessToken);
|
||||
try {
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await redis!.client.publish(
|
||||
buildGameEventChannel(profileName),
|
||||
JSON.stringify({ type: 'tournamentChanged' })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
|
||||
const actor = `security-http-durable-cutoff-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.set(key, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const fresh = await accessTokenStore.create({
|
||||
...buildPayload('fresh-cutoff', {}, actor),
|
||||
issuedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
expect(fresh).not.toBeNull();
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
|
||||
200
|
||||
);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(fresh!.accessToken);
|
||||
}
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
|
||||
const actor = `security-http-durable-missing-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missing', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.del(key);
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
|
||||
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(sse.status).toBe(503);
|
||||
await expect(createGameApiServer()).rejects.toThrow('startup refused');
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
// Flush invalidates every token issued before the user watermark. Keep it
|
||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||
// by the transport authorization matrix above.
|
||||
|
||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
||||
|
||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||
this.commands.push(command);
|
||||
if (command.type === 'adjustGeneralResources') {
|
||||
const adjustment = command.adjustments[0];
|
||||
if (!adjustment) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
if (command.type === 'tournamentAdjustGeneral') {
|
||||
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||
if (nextGold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
this.gold.set(command.generalId, nextGold);
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'setMySetting') {
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'adjustGeneralMeta') {
|
||||
if (this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
||||
}
|
||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
generalId: general.id,
|
||||
goldDelta: -64,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
betGoldDelta: 600,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([
|
||||
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 800,
|
||||
myTotals: { 12: 800 },
|
||||
totalAmount: 1_800,
|
||||
});
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 1_000,
|
||||
myTotals: { 11: 200, 12: 800 },
|
||||
});
|
||||
expect(transport.gold.get(1)).toBe(2_800);
|
||||
});
|
||||
|
||||
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(1, 3_000);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
expect(transport.gold.get(1)).toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.sourceRevision).toBe('41');
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
|
||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
||||
|
||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||
sendCommand: async () => 'ok',
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) =>
|
||||
command.type === 'tournamentSeedNpcBets'
|
||||
? {
|
||||
type: command.type,
|
||||
ok: true,
|
||||
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||
skippedGeneralIds: [],
|
||||
}
|
||||
: null,
|
||||
requestStatus: async () => null,
|
||||
});
|
||||
|
||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const daemonTransport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'tournament:123:npc-bet:resources',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'tournament:123:npc-bet:meta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: 'tournament:123:npc-bet:atomic',
|
||||
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||
},
|
||||
]);
|
||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toHaveLength(1);
|
||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||
await store.setBettingEntries([bets[0]!]);
|
||||
await seedNpcBets({
|
||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
});
|
||||
|
||||
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||
const state = createTournamentState({
|
||||
stage: 5,
|
||||
bettingId: 789,
|
||||
npcBettingPlan: [
|
||||
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||
],
|
||||
});
|
||||
await store.setState(state);
|
||||
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
requestCommand: async (command) => {
|
||||
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||
expect(opened.stage).toBe(6);
|
||||
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||
const state = createTournamentState({ stage: 5 });
|
||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
||||
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||
await store.setState(state);
|
||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
||||
let fail = true;
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
||||
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||
fail = false;
|
||||
// The accepted debit may already have committed before retry.
|
||||
prisma.general.findMany = async () => [];
|
||||
throw new Error('enqueue unavailable');
|
||||
}
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
);
|
||||
expect(await store.getState()).toMatchObject({
|
||||
stage: 5,
|
||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
||||
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||
});
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
||||
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||
expect(commands.map((command) => command.requestId)).toEqual([
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
]);
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: expect.arrayContaining([
|
||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
||||
]),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
|
||||
select: {
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
clockTick: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
|
||||
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const zTournamentAdjustGeneral = z.object({
|
||||
type: z.literal('tournamentAdjustGeneral'),
|
||||
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
|
||||
userId: z.string().min(1),
|
||||
generalId: zSafeInteger.positive(),
|
||||
goldDelta: zFiniteNumber,
|
||||
betGoldDelta: zFiniteNumber.optional(),
|
||||
minGoldAfter: zFiniteNumber.nonnegative(),
|
||||
});
|
||||
|
||||
const zTournamentSeedNpcBets = z.object({
|
||||
type: z.literal('tournamentSeedNpcBets'),
|
||||
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
|
||||
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
|
||||
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
||||
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
||||
if (!command) {
|
||||
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
|
||||
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
};
|
||||
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'tournamentAdjustGeneral',
|
||||
'troopCreate',
|
||||
'troopJoin',
|
||||
'troopExit',
|
||||
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
|
||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
async function handleTournamentAdjustGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const general = ctx.world.getGeneralById(command.generalId);
|
||||
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
|
||||
if (!general || general.userId !== command.userId) {
|
||||
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
|
||||
}
|
||||
const gold = general.gold + command.goldDelta;
|
||||
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
|
||||
}
|
||||
const meta = { ...general.meta };
|
||||
if (command.betGoldDelta !== undefined) {
|
||||
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
|
||||
const betgold = current + command.betGoldDelta;
|
||||
if (!Number.isFinite(betgold) || betgold < 0) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
|
||||
}
|
||||
meta.betgold = betgold;
|
||||
}
|
||||
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
|
||||
ctx.world.updateGeneral(command.generalId, { gold, meta });
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
async function handleTournamentSeedNpcBets(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
if (ctx.commandDb) {
|
||||
const event = command.requestId
|
||||
? await ctx.commandDb.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true },
|
||||
})
|
||||
: null;
|
||||
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
|
||||
}
|
||||
}
|
||||
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
|
||||
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
|
||||
}
|
||||
const processedGeneralIds: number[] = [];
|
||||
const skippedGeneralIds: number[] = [];
|
||||
for (const bet of command.bets) {
|
||||
const general = ctx.world.getGeneralById(bet.generalId);
|
||||
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
|
||||
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
|
||||
skippedGeneralIds.push(bet.generalId);
|
||||
continue;
|
||||
}
|
||||
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
|
||||
ctx.world.updateGeneral(bet.generalId, {
|
||||
gold: general.gold - bet.amount,
|
||||
meta: { ...general.meta, betgold: current + bet.amount },
|
||||
});
|
||||
processedGeneralIds.push(bet.generalId);
|
||||
}
|
||||
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
|
||||
}
|
||||
|
||||
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
|
||||
reason !== undefined &&
|
||||
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
|
||||
reason
|
||||
);
|
||||
|
||||
async function handleAdjustGeneralResources(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
@@ -3269,6 +3346,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||
setNpcPolicy: (command) =>
|
||||
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
||||
tournamentAdjustGeneral: (command) =>
|
||||
handleTournamentAdjustGeneral(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||
),
|
||||
tournamentSeedNpcBets: (command) =>
|
||||
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
|
||||
adjustGeneralResources: (command) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
|
||||
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{
|
||||
type: 'tournamentAdjustGeneral',
|
||||
reason: 'tournamentBet',
|
||||
requestId: 'tournamentAdjustGeneral',
|
||||
userId,
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
|
||||
@@ -274,69 +274,79 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
||||
const requestId = 'integration:engine:stale-owner-replay';
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
|
||||
'stores a stale-owner %s rejection once and never redispatches it',
|
||||
async (type) => {
|
||||
const requestId = `integration:engine:stale-owner-replay:${type}`;
|
||||
const command: TurnDaemonCommand =
|
||||
type === 'vacation'
|
||||
? { type, requestId, userId: 'old-owner', generalId: 7 }
|
||||
: {
|
||||
type,
|
||||
reason: 'tournamentBet',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||
@@ -630,6 +640,8 @@ integration('database command queue', () => {
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
const atomicBetId = 'integration:engine:suspended-atomic-bet';
|
||||
const atomicJoinId = 'integration:engine:suspended-atomic-join';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -679,6 +691,41 @@ integration('database command queue', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: atomicBetId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentBet',
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: atomicJoinId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicJoinId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentJoin',
|
||||
goldDelta: -200,
|
||||
minGoldAfter: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
@@ -688,7 +735,15 @@ integration('database command queue', () => {
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
reason: 'tournamentBet',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
|
||||
@@ -79,6 +79,83 @@ const buildWorld = (
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
describe('tournament mutation ownership', () => {
|
||||
it('checks the current owner and commits gold plus bet statistics together', async () => {
|
||||
const general = { ...buildGeneral(1), userId: 'owner' };
|
||||
const world = buildWorld([general]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'tournamentAdjustGeneral' as const,
|
||||
reason: 'tournamentBet' as const,
|
||||
userId: 'owner',
|
||||
generalId: 1,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
world.updateGeneral(1, { userId: 'new-owner' });
|
||||
const transferred = structuredClone(world.getGeneralById(1));
|
||||
for (const goldDelta of [-100, 100]) {
|
||||
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(transferred);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
|
||||
const world = buildWorld([
|
||||
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
|
||||
buildGeneral(2, {}, 2),
|
||||
{ ...buildGeneral(3, {}, 2), gold: 509 },
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const possessed = structuredClone(world.getGeneralById(1));
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
ok: true,
|
||||
processedGeneralIds: [2],
|
||||
skippedGeneralIds: [1, 3],
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(possessed);
|
||||
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
|
||||
});
|
||||
|
||||
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
|
||||
'rejects actorless legacy %s commands before mutation',
|
||||
async (reason) => {
|
||||
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
for (const command of [
|
||||
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
|
||||
{
|
||||
type: 'adjustGeneralMeta' as const,
|
||||
reason,
|
||||
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
])
|
||||
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('tournament world commands', () => {
|
||||
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
|
||||
const currentWorld = buildWorld([], {
|
||||
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
|
||||
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
|
||||
const world = buildWorld(generals);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
|
||||
}
|
||||
}
|
||||
await handler.handle({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: generals.map((general) => ({
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: 1_000 },
|
||||
})),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
|
||||
});
|
||||
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
|
||||
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
|
||||
@@ -880,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
|
||||
const installRealtimeHarness = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
class TestEventSource extends EventTarget {
|
||||
static latest: TestEventSource | null = null;
|
||||
static created = 0;
|
||||
static closed = 0;
|
||||
readonly url: string;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = url.toString();
|
||||
TestEventSource.created += 1;
|
||||
TestEventSource.latest = this;
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('open')));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (TestEventSource.latest === this) {
|
||||
TestEventSource.latest = null;
|
||||
TestEventSource.closed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource });
|
||||
Object.defineProperty(window, '__emitMainRealtime', {
|
||||
configurable: true,
|
||||
value: (type: string, payload: unknown) => {
|
||||
TestEventSource.latest?.dispatchEvent(
|
||||
new MessageEvent(type, {
|
||||
data: JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
|
||||
let created = 0;
|
||||
let closed = 0;
|
||||
let authenticated = false;
|
||||
let credentialInUrl = false;
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
||||
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
|
||||
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
|
||||
credentialInUrl = url.searchParams.has('token');
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let ended = false;
|
||||
const source = {
|
||||
close: () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
controller.close();
|
||||
},
|
||||
emit: (type: string, payload: unknown) => {
|
||||
if (ended) return;
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`event: ${type}\ndata: ${JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (next) => {
|
||||
controller = next;
|
||||
},
|
||||
cancel: () => {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
latest = source;
|
||||
init?.signal?.addEventListener('abort', source.close, { once: true });
|
||||
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
};
|
||||
Object.assign(window, {
|
||||
__emitMainRealtime: (type: string, payload: unknown) => {
|
||||
latest?.emit(type, payload);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, '__hasMainRealtime', {
|
||||
configurable: true,
|
||||
value: () => TestEventSource.latest !== null,
|
||||
});
|
||||
Object.defineProperty(window, '__mainRealtimeStats', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
active: TestEventSource.latest !== null,
|
||||
created: TestEventSource.created,
|
||||
closed: TestEventSource.closed,
|
||||
}),
|
||||
__hasMainRealtime: () => latest !== null,
|
||||
__mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -5430,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean };
|
||||
}
|
||||
).__mainRealtimeStats()
|
||||
)
|
||||
).toMatchObject({ authenticated: true, credentialInUrl: false });
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders).toContain(null);
|
||||
|
||||
const captureRefreshLayout = async (phase: string): Promise<void> => {
|
||||
if (!autoRefreshArtifactRoot) return;
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const layout = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
|
||||
cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map(
|
||||
(element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
target: element.getAttribute('data-main-target'),
|
||||
bounds: element.getBoundingClientRect().toJSON(),
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
display: style.display,
|
||||
};
|
||||
}
|
||||
),
|
||||
images: Array.from(document.images).map((image) => ({
|
||||
src: image.getAttribute('src'),
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
complete: image.complete,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
})),
|
||||
}));
|
||||
await Promise.all([
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)),
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()),
|
||||
]);
|
||||
};
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
|
||||
@@ -5473,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
}).observe({ entryTypes: ['measure'] });
|
||||
});
|
||||
|
||||
await captureRefreshLayout('before-refresh');
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
const operationsBeforeClockOnly = state.operations.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
@@ -5616,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
|
||||
};
|
||||
});
|
||||
await captureRefreshLayout('after-refresh');
|
||||
expect(profile.generalMounted).toBe(true);
|
||||
expect(profile.cityMounted).toBe(true);
|
||||
expect(profile.generalMutations).toBeGreaterThan(0);
|
||||
|
||||
@@ -371,37 +371,57 @@ const installFixture = async (
|
||||
|
||||
const installFakeEventSource = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
class FakeEventSource extends EventTarget {
|
||||
static instances: FakeEventSource[] = [];
|
||||
readonly url: string;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = String(url);
|
||||
FakeEventSource.instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (!this.closed) this.dispatchEvent(new Event('open'));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown) {
|
||||
if (this.closed) return;
|
||||
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const sources: Array<{
|
||||
url: string;
|
||||
closed: boolean;
|
||||
authenticated: boolean;
|
||||
emit: (type: string, payload: unknown) => void;
|
||||
}> = [];
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
||||
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
|
||||
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const source = {
|
||||
url: url.href,
|
||||
closed: false,
|
||||
authenticated: new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false,
|
||||
emit: (type: string, payload: unknown) => {
|
||||
if (!source.closed)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
|
||||
);
|
||||
},
|
||||
};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (next) => {
|
||||
controller = next;
|
||||
},
|
||||
cancel: () => {
|
||||
source.closed = true;
|
||||
},
|
||||
});
|
||||
sources.push(source);
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
if (!source.closed) {
|
||||
source.closed = true;
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
};
|
||||
Object.assign(window, {
|
||||
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
|
||||
__tournamentEventSourceUrls: () =>
|
||||
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
|
||||
__tournamentEventSourceCount: () => sources.filter((source) => !source.closed).length,
|
||||
__tournamentEventSourceUrls: () => sources.filter((source) => !source.closed).map((source) => source.url),
|
||||
__tournamentEventSourceAuthenticated: () =>
|
||||
sources.filter((source) => !source.closed).every((source) => source.authenticated),
|
||||
__emitTournamentEvent: (type: string, payload: unknown) => {
|
||||
for (const source of FakeEventSource.instances) source.emit(type, payload);
|
||||
for (const source of sources) source.emit(type, payload);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -885,6 +905,18 @@ test('betting realtime refresh is shared across tabs and preserves local interac
|
||||
).flat();
|
||||
expect(activeSourceUrls).toHaveLength(1);
|
||||
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
|
||||
expect(new URL(activeSourceUrls[0]!).searchParams.has('token')).toBe(false);
|
||||
for (const candidate of [page, follower]) {
|
||||
expect(
|
||||
await candidate.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__tournamentEventSourceAuthenticated: () => boolean;
|
||||
}
|
||||
).__tournamentEventSourceAuthenticated()
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const before = {
|
||||
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
|
||||
import { receiveClockEngineState } from '../composables/useClockDisplay';
|
||||
import { computed, ref, toRaw, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
@@ -1139,16 +1140,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
let realtimeSource: EventSource | null = null;
|
||||
let realtimeSource: AuthenticatedEventSource | null = null;
|
||||
let realtimeToken: string | null = null;
|
||||
let visibilityListenerInstalled = false;
|
||||
|
||||
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
||||
|
||||
const buildRealtimeUrl = (token: string): string => {
|
||||
const buildRealtimeUrl = (): string => {
|
||||
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
@@ -1284,15 +1284,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeToken = token;
|
||||
realtimeStatus.value = 'idle';
|
||||
|
||||
const source = new EventSource(buildRealtimeUrl(token));
|
||||
let needsRecovery = false;
|
||||
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
|
||||
realtimeSource = source;
|
||||
|
||||
source.addEventListener('open', () => {
|
||||
markGameServerContact();
|
||||
realtimeStatus.value = 'connected';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
|
||||
if (needsRecovery) {
|
||||
needsRecovery = false;
|
||||
realtimeRefreshQueue.request();
|
||||
}
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
source.addEventListener('error', (event) => {
|
||||
if (event.code === 401) void session.refreshGeneralStatus();
|
||||
needsRecovery = true;
|
||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
|
||||
@@ -49,7 +50,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
const realtimeStatus = ref<'idle' | 'connected'>('idle');
|
||||
|
||||
let activeConsumers = 0;
|
||||
let realtimeSource: EventSource | null = null;
|
||||
let realtimeSource: AuthenticatedEventSource | null = null;
|
||||
let realtimeToken: string | null = null;
|
||||
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
|
||||
let realtimeCoordinatorScope: string | null = null;
|
||||
@@ -136,10 +137,9 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
if (!(await session.exchangeGatewayToken())) return null;
|
||||
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
|
||||
};
|
||||
const buildRealtimeUrl = (token: string): string => {
|
||||
const buildRealtimeUrl = (): string => {
|
||||
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
url.searchParams.set('scope', 'tournament');
|
||||
return url.toString();
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
if (realtimeSource && realtimeToken === token) return;
|
||||
closeRealtimeSource();
|
||||
realtimeToken = token;
|
||||
const source = new EventSource(buildRealtimeUrl(token));
|
||||
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
|
||||
realtimeSource = source;
|
||||
source.addEventListener('open', () => {
|
||||
realtimeStatus.value = 'connected';
|
||||
@@ -196,7 +196,8 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
source.addEventListener('error', (event) => {
|
||||
if (event.code === 401) void session.refreshGeneralStatus();
|
||||
needsRecovery = true;
|
||||
realtimeStatus.value = 'idle';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
export type AuthenticatedEventSource = EventSource;
|
||||
|
||||
/** Keep the access token out of browser/proxy URLs and ambient cookies. */
|
||||
export const createAuthenticatedEventSource = (url: string, accessToken: string): EventSource => {
|
||||
const destination = new URL(url);
|
||||
if (destination.searchParams.has('token') || destination.username || destination.password) {
|
||||
throw new Error('Realtime credentials must use the authorization header.');
|
||||
}
|
||||
return new EventSource(destination, {
|
||||
maxBufferSize: 64 * 1024,
|
||||
fetch: (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import test from 'node:test';
|
||||
import { createAuthenticatedEventSource } from '../src/utils/authenticatedEventSource.ts';
|
||||
|
||||
const listen = (server: Server): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
|
||||
resolve(`http://127.0.0.1:${address.port}`);
|
||||
});
|
||||
});
|
||||
const stop = (server: Server): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
void test(
|
||||
'reconnects with header auth, omits ambient credentials and preserves named UTF-8 SSE events',
|
||||
{ timeout: 5_000 },
|
||||
async () => {
|
||||
const requests: Array<{ url: string; authorization: string | undefined; cookie: string | undefined }> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({
|
||||
url: request.url!,
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
});
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
response.write(
|
||||
`retry: 20\nevent: update\ndata: ${JSON.stringify({ value: '갱신', attempt: requests.length })}\n\n`
|
||||
);
|
||||
if (requests.length === 1) response.end();
|
||||
});
|
||||
const origin = await listen(server);
|
||||
const source = createAuthenticatedEventSource(`${origin}/events?scope=tournament`, 'ga_synthetic-fixture');
|
||||
try {
|
||||
const events: unknown[] = [];
|
||||
await new Promise<void>((resolve) =>
|
||||
source.addEventListener('update', (event) => {
|
||||
events.push(JSON.parse(event.data));
|
||||
if (events.length === 2) resolve();
|
||||
})
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ value: '갱신', attempt: 1 },
|
||||
{ value: '갱신', attempt: 2 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
Array.from({ length: 2 }, () => ({
|
||||
url: '/events?scope=tournament',
|
||||
authorization: 'Bearer ga_synthetic-fixture',
|
||||
cookie: undefined,
|
||||
}))
|
||||
);
|
||||
} finally {
|
||||
source.close();
|
||||
await stop(server);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
void test('401 is terminal and redirects never forward the authorization header', { timeout: 5_000 }, async () => {
|
||||
let redirectedRequests = 0;
|
||||
const target = createServer((_req, res) => {
|
||||
redirectedRequests += 1;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
});
|
||||
const targetOrigin = await listen(target);
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(req.url === '/unauthorized' ? 401 : 302, { location: targetOrigin });
|
||||
res.end();
|
||||
});
|
||||
const origin = await listen(server);
|
||||
try {
|
||||
for (const endpoint of ['unauthorized', 'redirect']) {
|
||||
const source = createAuthenticatedEventSource(`${origin}/${endpoint}`, 'ga_synthetic-fixture');
|
||||
try {
|
||||
const code = await new Promise<number | undefined>((resolve) =>
|
||||
source.addEventListener('error', (event) => resolve(event.code))
|
||||
);
|
||||
if (endpoint === 'unauthorized') {
|
||||
assert.equal(code, 401);
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
assert.equal(redirectedRequests, 0);
|
||||
} finally {
|
||||
await stop(server);
|
||||
await stop(target);
|
||||
}
|
||||
});
|
||||
|
||||
void test('rejects configured query or URL credentials before connecting', () => {
|
||||
assert.throws(() => createAuthenticatedEventSource('http://localhost/events?token=synthetic', 'ga_synthetic'));
|
||||
assert.throws(() => createAuthenticatedEventSource('http://user:synthetic@localhost/events', 'ga_synthetic'));
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface UserIconUploadResult {
|
||||
picture: string;
|
||||
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
|
||||
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
|
||||
}
|
||||
|
||||
const signature = (
|
||||
secret: string,
|
||||
expires: string,
|
||||
requestId: string,
|
||||
pathname: string,
|
||||
contentType: string,
|
||||
body: Buffer
|
||||
): string => {
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid user icon filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature(
|
||||
this.secret,
|
||||
expires,
|
||||
requestId,
|
||||
pathname,
|
||||
input.contentType,
|
||||
input.body
|
||||
),
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'user-icons',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
const picture = `users/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
|
||||
throw new Error('Image repository returned an unexpected upload path.');
|
||||
}
|
||||
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export interface AuthCounter {
|
||||
key: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export interface AuthCounterStore {
|
||||
readonly available?: boolean;
|
||||
/** Atomically check every counter, then charge them only when accepted. */
|
||||
consume(counters: AuthCounter[]): Promise<number>;
|
||||
}
|
||||
|
||||
export class AuthBudgetError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'limited' | 'unavailable',
|
||||
readonly retryAfterSeconds = 1
|
||||
) {
|
||||
super(
|
||||
reason === 'limited'
|
||||
? 'Authentication attempts are temporarily limited.'
|
||||
: 'Authentication protection is unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CONSUME_SCRIPT = `
|
||||
local retry = 0
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = tonumber(redis.call('GET', key) or '0')
|
||||
if count >= tonumber(ARGV[i * 2 - 1]) then
|
||||
local ttl = redis.call('PTTL', key)
|
||||
if ttl < 0 then
|
||||
return -1
|
||||
end
|
||||
retry = math.max(retry, math.max(ttl, 1))
|
||||
end
|
||||
end
|
||||
if retry > 0 then return retry end
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = redis.call('INCR', key)
|
||||
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
export class RedisAuthCounterStore implements AuthCounterStore {
|
||||
constructor(
|
||||
private readonly client: {
|
||||
readonly isReady?: boolean;
|
||||
withAbortSignal?: (signal: AbortSignal) => {
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
};
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
}
|
||||
) {}
|
||||
|
||||
get available(): boolean {
|
||||
return this.client.isReady !== false;
|
||||
}
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
|
||||
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
const result = await client.eval(CONSUME_SCRIPT, {
|
||||
keys: counters.map((counter) => counter.key),
|
||||
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
|
||||
});
|
||||
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
|
||||
export class InMemoryAuthCounterStore implements AuthCounterStore {
|
||||
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
|
||||
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
let retryMs = 0;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key);
|
||||
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
|
||||
}
|
||||
if (retryMs > 0) return retryMs;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
|
||||
entry.count += 1;
|
||||
this.entries.set(counter.key, entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const EXPENSIVE_ACTIONS = new Set([
|
||||
'auth.bootstrapLocal',
|
||||
'auth.kakaoExchange',
|
||||
'auth.kakaoResolveAccount',
|
||||
'auth.kakaoSetPassword',
|
||||
'auth.register',
|
||||
'auth.registerLocal',
|
||||
'auth.login',
|
||||
'auth.kakaoOtp',
|
||||
'account.changePassword',
|
||||
'account.scheduleDeletion',
|
||||
]);
|
||||
const LIGHTWEIGHT_ACTIONS = new Set([
|
||||
'auth.passwordKey',
|
||||
'auth.checkRegistrationField',
|
||||
'auth.kakaoStart',
|
||||
'auth.issueGameSession',
|
||||
]);
|
||||
|
||||
export const isBudgetedAuthAction = (action: string): boolean =>
|
||||
EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action);
|
||||
|
||||
export class AuthAttemptBudget {
|
||||
private active = 0;
|
||||
private pendingChecks = 0;
|
||||
|
||||
constructor(
|
||||
private readonly store: AuthCounterStore,
|
||||
private readonly prefix: string,
|
||||
private readonly secret: string
|
||||
) {}
|
||||
|
||||
private key(kind: string, value: string): string {
|
||||
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
|
||||
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
action: string,
|
||||
client: string,
|
||||
subject: string | undefined | (() => Promise<string | undefined>),
|
||||
work: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
|
||||
const expensive = EXPENSIVE_ACTIONS.has(action);
|
||||
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
|
||||
if (expensive) this.active += 1;
|
||||
try {
|
||||
const consume = async (): Promise<number> => {
|
||||
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
|
||||
const counters: AuthCounter[] = [
|
||||
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
|
||||
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
|
||||
];
|
||||
if (resolvedSubject)
|
||||
counters.push({
|
||||
key: this.key('subject', resolvedSubject),
|
||||
limit: action === 'auth.issueGameSession' ? 32 : 10,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
return this.store.consume(counters);
|
||||
};
|
||||
this.pendingChecks += 1;
|
||||
const checking = consume();
|
||||
// A caller timeout does not prove the underlying Redis/actor read settled.
|
||||
void checking.then(
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
},
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
}
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryMs: number;
|
||||
try {
|
||||
retryMs = await Promise.race([
|
||||
checking,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
|
||||
return await work();
|
||||
} finally {
|
||||
if (expensive) this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,28 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
interface RedisClientLike {
|
||||
readonly isReady?: boolean;
|
||||
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
withAbortSignal?(signal: AbortSignal): RedisClientLike;
|
||||
}
|
||||
|
||||
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
|
||||
const PUBLISH_FLUSH_SCRIPT = `
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'NX')
|
||||
local raw = redis.call('GET', KEYS[2])
|
||||
local prior = tonumber(raw)
|
||||
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
|
||||
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
|
||||
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
|
||||
redis.call('PUBLISH', ARGV[2], ARGV[3])
|
||||
return 1
|
||||
`;
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
private readonly channel: string;
|
||||
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly commands = new SessionRevocationCommandBudget(32);
|
||||
|
||||
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
||||
constructor(client: RedisClientLike, channel: string) {
|
||||
this.client = client;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
private commandClient(): RedisClientLike {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
|
||||
await this.commands.run(() =>
|
||||
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
|
||||
NX: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async publishUserFlush(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
|
||||
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
const result = await this.commands.run(() =>
|
||||
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
|
||||
keys: [
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
],
|
||||
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
|
||||
})
|
||||
);
|
||||
if (result !== 1) throw new Error('Session revocation persistence failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
|
||||
|
||||
import type {
|
||||
GameSessionInfo,
|
||||
@@ -60,7 +61,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.sessions.delete(sessionToken);
|
||||
this.sessionGames.delete(sessionToken);
|
||||
return null;
|
||||
@@ -86,9 +87,19 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
|
||||
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
|
||||
const session = await this.getSession(sessionToken);
|
||||
if (!session) {
|
||||
const parent = this.sessions.get(sessionToken);
|
||||
if (!session || !parent || parent.info !== session || parent.expiresAt <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
|
||||
for (const key of set) {
|
||||
const stored = this.gameSessions.get(key);
|
||||
if (!stored || stored.expiresAt <= Date.now()) {
|
||||
this.gameSessions.delete(key);
|
||||
set.delete(key);
|
||||
}
|
||||
}
|
||||
if (set.size >= MAX_GAME_SESSIONS_PER_SESSION) throw new GameSessionLimitError();
|
||||
const gameToken = randomUUID();
|
||||
const info: GameSessionInfo = {
|
||||
profile,
|
||||
@@ -109,7 +120,6 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
info,
|
||||
expiresAt: Date.now() + this.gameSessionTtlMs,
|
||||
});
|
||||
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
|
||||
set.add(key);
|
||||
this.sessionGames.set(sessionToken, set);
|
||||
return info;
|
||||
@@ -121,7 +131,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.gameSessions.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
|
||||
redirectUri: string;
|
||||
oauthHost?: string;
|
||||
apiHost?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface KakaoOAuthToken {
|
||||
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
|
||||
private readonly redirectUri: string;
|
||||
private readonly oauthHost: string;
|
||||
private readonly apiHost: string;
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(config: KakaoOAuthConfig) {
|
||||
this.restKey = config.restKey;
|
||||
this.redirectUri = config.redirectUri;
|
||||
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
||||
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
|
||||
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
|
||||
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
|
||||
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
url: URL,
|
||||
init: RequestInit
|
||||
): Promise<{ response: Response; payload: Record<string, unknown> }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
if (!response.body) throw new Error('Missing response body.');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 64 * 1024) {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
throw new Error('Response is too large.');
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const payload: unknown = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
||||
);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
||||
throw new Error('Invalid response shape.');
|
||||
return { response, payload: payload as Record<string, unknown> };
|
||||
} catch {
|
||||
// Provider/network payloads can include access tokens or account details.
|
||||
throw new Error('Kakao HTTP request failed.');
|
||||
}
|
||||
}
|
||||
|
||||
buildAuthUrl(state: string, scopes: string[]): string {
|
||||
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
|
||||
code,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth token error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth refresh error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async signup(accessToken: string): Promise<KakaoSignupResult> {
|
||||
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
|
||||
return {
|
||||
alreadyRegistered: true,
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao signup error.');
|
||||
}
|
||||
return {
|
||||
id: payload.id ? String(payload.id) : undefined,
|
||||
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async getMe(accessToken: string): Promise<KakaoUserInfo> {
|
||||
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao me error.');
|
||||
}
|
||||
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
|
||||
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const code = Number(payload.code ?? 0);
|
||||
if (!response.ok || code < 0) {
|
||||
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao talk message error.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { parseJson } from '@sammo-ts/common';
|
||||
|
||||
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
|
||||
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
|
||||
import type {
|
||||
GameSessionInfo,
|
||||
GatewaySessionConfig,
|
||||
@@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig {
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
interface RedisPipeline {
|
||||
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
|
||||
sAdd(key: string, member: string): RedisPipeline;
|
||||
expire(key: string, seconds: number): RedisPipeline;
|
||||
del(key: string): RedisPipeline;
|
||||
exec(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||
sMembers(key: string): Promise<string[]>;
|
||||
multi(): RedisPipeline;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
}
|
||||
|
||||
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
|
||||
// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다.
|
||||
const CREATE_GAME_SESSION_SCRIPT = `
|
||||
local parentTtl = redis.call('PTTL', KEYS[1])
|
||||
if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
|
||||
local limit = tonumber(ARGV[4])
|
||||
if redis.call('SCARD', KEYS[2]) > limit then return -1 end
|
||||
for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do
|
||||
if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end
|
||||
end
|
||||
if redis.call('SCARD', KEYS[2]) >= limit then return -1 end
|
||||
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3])
|
||||
redis.call('SADD', KEYS[2], KEYS[3])
|
||||
redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000))
|
||||
return 1
|
||||
`;
|
||||
|
||||
// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다.
|
||||
const REVOKE_GAME_SESSION_BATCH_SCRIPT = `
|
||||
redis.call('DEL', KEYS[1])
|
||||
local entries = redis.call('SPOP', KEYS[2], 64)
|
||||
for _, entry in ipairs(entries) do redis.call('DEL', entry) end
|
||||
return #entries
|
||||
`;
|
||||
|
||||
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
|
||||
export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
private readonly client: RedisClientLike;
|
||||
@@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
options: SessionRevocationOptions = { revokeGames: true }
|
||||
): Promise<void> {
|
||||
const key = this.keys.sessionKey(sessionToken);
|
||||
if (options.revokeGames ?? true) {
|
||||
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||
const games = await this.client.sMembers(gameSetKey);
|
||||
if (games.length > 0) {
|
||||
const pipeline = this.client.multi();
|
||||
for (const entry of games) {
|
||||
pipeline.del(entry);
|
||||
}
|
||||
pipeline.del(gameSetKey);
|
||||
pipeline.del(key);
|
||||
await pipeline.exec();
|
||||
return;
|
||||
}
|
||||
await this.client.del(gameSetKey);
|
||||
if (!(options.revokeGames ?? true)) {
|
||||
await this.client.del(key);
|
||||
return;
|
||||
}
|
||||
// 부모 제거와 각 64개 삭제를 원자적으로 처리한다. 이후 발급 Lua는 실패한다.
|
||||
// 이전 버전에서 커진 index도 응답/transaction 한 번에 전부 펼치지 않는다.
|
||||
for (;;) {
|
||||
const count = await this.client.eval(REVOKE_GAME_SESSION_BATCH_SCRIPT, {
|
||||
keys: [key, this.keys.sessionGameSetKey(sessionToken)],
|
||||
arguments: [],
|
||||
});
|
||||
if (typeof count !== 'number' || count < 0 || count > 64)
|
||||
throw new Error('Unexpected game session revocation result.');
|
||||
if (count < 64) break;
|
||||
}
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
|
||||
@@ -115,12 +131,18 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
||||
};
|
||||
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||
await this.client
|
||||
.multi()
|
||||
.set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds })
|
||||
.sAdd(gameSetKey, gameKey)
|
||||
.expire(gameSetKey, this.sessionTtlSeconds)
|
||||
.exec();
|
||||
const result = await this.client.eval(CREATE_GAME_SESSION_SCRIPT, {
|
||||
keys: [this.keys.sessionKey(sessionToken), gameSetKey, gameKey],
|
||||
arguments: [
|
||||
JSON.stringify(session),
|
||||
JSON.stringify(info),
|
||||
String(this.gameSessionTtlSeconds),
|
||||
String(MAX_GAME_SESSIONS_PER_SESSION),
|
||||
],
|
||||
});
|
||||
if (result === 0) return null;
|
||||
if (result === -1) throw new GameSessionLimitError();
|
||||
if (result !== 1) throw new Error('Unexpected game session admission result.');
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { UserRecord, UserSanctions } from './userRepository.js';
|
||||
|
||||
// 한 Gateway 로그인에서 여러 profile/탭을 사용하되 추적 자료는 유한하게 유지한다.
|
||||
export const MAX_GAME_SESSIONS_PER_SESSION = 256;
|
||||
export class GameSessionLimitError extends Error {
|
||||
constructor() {
|
||||
super('Game session limit reached.');
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatewaySessionInfo {
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
|
||||
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||
import type { UserRepository } from './auth/userRepository.js';
|
||||
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
|
||||
releases: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget: AuthAttemptBudget;
|
||||
requestIp: string;
|
||||
requestHeaders: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit: AdminAuditStore;
|
||||
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
|
||||
releases?: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget?: AuthAttemptBudget;
|
||||
requestIp?: string;
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
|
||||
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
authBudget:
|
||||
options.authBudget ??
|
||||
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
|
||||
requestIp: options.requestIp ?? 'unknown',
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
prisma: options.prisma,
|
||||
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface BuildCommand {
|
||||
@@ -33,7 +34,8 @@ export interface BuildRunner {
|
||||
|
||||
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
|
||||
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
|
||||
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
|
||||
const RELEASE_BUILD_ENV_NAME =
|
||||
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
|
||||
|
||||
export const sanitizeReleaseBuildEnv = (
|
||||
env: NodeJS.ProcessEnv | Record<string, string> | undefined
|
||||
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
|
||||
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
|
||||
if (!record(value)) return false;
|
||||
if (Object.keys(value).length !== 1) return false;
|
||||
if (value.result) {
|
||||
const result = value.result;
|
||||
return (
|
||||
record(result) &&
|
||||
typeof result.ok === 'boolean' &&
|
||||
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
|
||||
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
|
||||
typeof result.output === 'string' &&
|
||||
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
|
||||
(result.aborted === undefined || typeof result.aborted === 'boolean')
|
||||
);
|
||||
}
|
||||
if (typeof value.error === 'string') return value.error.length <= 2000;
|
||||
if (!record(value.event)) return false;
|
||||
const event = value.event;
|
||||
if (event.type === 'OUTPUT')
|
||||
return (
|
||||
typeof event.stream === 'string' &&
|
||||
['stdout', 'stderr'].includes(event.stream) &&
|
||||
typeof event.message === 'string' &&
|
||||
event.message.length <= 2000
|
||||
);
|
||||
if (
|
||||
typeof event.type !== 'string' ||
|
||||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
|
||||
!record(event.command)
|
||||
)
|
||||
return false;
|
||||
const command = event.command;
|
||||
if (
|
||||
typeof command.command !== 'string' ||
|
||||
!['pnpm', 'node'].includes(command.command) ||
|
||||
typeof command.cwd !== 'string' ||
|
||||
command.cwd.length > 2048 ||
|
||||
!Array.isArray(command.args) ||
|
||||
command.args.length > 40 ||
|
||||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
command.env !== undefined &&
|
||||
(!record(command.env) ||
|
||||
Object.keys(command.env).length > 32 ||
|
||||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
event.type === 'COMMAND_START' ||
|
||||
event.exitCode === null ||
|
||||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
|
||||
);
|
||||
};
|
||||
|
||||
const readRemoteBuildToken = (filename: string | undefined): string => {
|
||||
try {
|
||||
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
|
||||
const token = readFileSync(filename, 'utf8').trim();
|
||||
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
|
||||
return token;
|
||||
} catch {
|
||||
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
|
||||
}
|
||||
};
|
||||
|
||||
export interface RemoteBuildOptions {
|
||||
tokenFile?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class RemoteBuildRunner implements BuildRunner {
|
||||
private readonly endpoint: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
|
||||
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
private readonly config: RemoteBuildOptions = {}
|
||||
) {
|
||||
const parsed = new URL('/v1/builds', baseUrl);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
||||
throw new Error('Invalid release builder endpoint.');
|
||||
}
|
||||
this.endpoint = parsed.toString();
|
||||
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
|
||||
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
|
||||
throw new Error('Invalid release builder deadline.');
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
|
||||
options?: BuildRunOptions
|
||||
): Promise<BuildResult> {
|
||||
let output = '';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
timeout.unref();
|
||||
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const token = readRemoteBuildToken(this.config.tokenFile);
|
||||
const response = await this.fetchImpl(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
redirect: 'error',
|
||||
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
|
||||
body: JSON.stringify({
|
||||
commands: commands.map((command) => ({
|
||||
...command,
|
||||
env: sanitizeReleaseBuildEnv(command.env),
|
||||
})),
|
||||
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
|
||||
}),
|
||||
signal: options?.signal,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
|
||||
};
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let buffer = '';
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const result = await this.handleRemoteMessage(line, onProgress);
|
||||
if (result.event?.type === 'OUTPUT') {
|
||||
output = appendOutputTail(output, `${result.event.message}\n`);
|
||||
}
|
||||
if (result.result) return result.result;
|
||||
if (result.error) {
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
buffer += decoder.decode();
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
const result = await this.handleRemoteMessage(line, onProgress);
|
||||
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
|
||||
if (result.result) return result.result;
|
||||
if (result.error)
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const result = await this.handleRemoteMessage(buffer, onProgress);
|
||||
if (result.result) return result.result;
|
||||
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
|
||||
}
|
||||
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(output, 'Release builder closed without a result.'),
|
||||
};
|
||||
} catch {
|
||||
const aborted = options?.signal?.aborted ?? false;
|
||||
return {
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
output: appendOutputTail(
|
||||
output,
|
||||
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
|
||||
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
|
||||
),
|
||||
...(aborted ? { aborted: true } : {}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
await reader?.cancel().catch(() => undefined);
|
||||
reader?.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRemoteMessage(
|
||||
line: string,
|
||||
onProgress?: BuildProgressObserver
|
||||
): Promise<RemoteBuildMessage> {
|
||||
let message: RemoteBuildMessage;
|
||||
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
|
||||
if (!line.trim()) return {};
|
||||
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
|
||||
return { error: 'Release builder frame is too large.' };
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line) as RemoteBuildMessage;
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return { error: 'Release builder returned malformed progress data.' };
|
||||
}
|
||||
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
|
||||
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
|
||||
if (message.event && onProgress) await onProgress(message.event);
|
||||
return message;
|
||||
}
|
||||
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
|
||||
export const createReleaseBuildRunner = (
|
||||
baseUrl: string | undefined,
|
||||
localRunner: BuildRunner,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
tokenFile?: string
|
||||
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
|
||||
|
||||
@@ -1024,7 +1024,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(
|
||||
options.processConfig.releaseBuilderUrl,
|
||||
options.buildRunner,
|
||||
options.fetchImpl ?? fetch
|
||||
options.fetchImpl ?? fetch,
|
||||
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
|
||||
);
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
this.processConfig = options.processConfig;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
|
||||
const t = initTRPC.context<GatewayApiContext>().create();
|
||||
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
|
||||
if (!isBudgetedAuthAction(path)) return next();
|
||||
const raw: unknown = await getRawInput();
|
||||
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
const resolveSubject = async (): Promise<string | undefined> => {
|
||||
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
|
||||
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
|
||||
}
|
||||
if (
|
||||
(path.startsWith('account.') || path === 'auth.issueGameSession') &&
|
||||
typeof input.sessionToken === 'string' &&
|
||||
input.sessionToken.length <= 256
|
||||
) {
|
||||
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
try {
|
||||
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthBudgetError)) throw error;
|
||||
throw new TRPCError({
|
||||
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
|
||||
message:
|
||||
error.reason === 'limited'
|
||||
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
|
||||
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sendBoundedNotification } from './sendNotification.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
|
||||
try {
|
||||
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
|
||||
parsePushEndpoint(delivery.subscription.endpoint);
|
||||
await webPush.sendNotification(
|
||||
await sendBoundedNotification(
|
||||
{
|
||||
endpoint: delivery.subscription.endpoint,
|
||||
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
|
||||
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
|
||||
body: delivery.notification.body,
|
||||
url: delivery.notification.url,
|
||||
tag: delivery.notification.tag,
|
||||
}),
|
||||
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
|
||||
})
|
||||
);
|
||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request, type RequestOptions } from 'node:https';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import webPush from 'web-push';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
|
||||
|
||||
export class PushTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode = 0
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
|
||||
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
||||
|
||||
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
|
||||
export const sendPushRequest = (
|
||||
details: webPush.RequestDetails,
|
||||
requestImpl: PushRequest = request,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const endpoint = parsePushEndpoint(details.endpoint);
|
||||
let response: IncomingMessage | undefined;
|
||||
let req: ClientRequest | undefined;
|
||||
let settled = false;
|
||||
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
response?.destroy();
|
||||
req?.destroy();
|
||||
reject(error);
|
||||
} else resolve();
|
||||
};
|
||||
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
|
||||
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
|
||||
try {
|
||||
req = requestImpl(
|
||||
{
|
||||
protocol: 'https:',
|
||||
hostname: endpoint.hostname,
|
||||
port: 443,
|
||||
path: `${endpoint.pathname}${endpoint.search}`,
|
||||
method: details.method,
|
||||
headers: details.headers,
|
||||
agent: publicPushAgent,
|
||||
},
|
||||
(incoming) => {
|
||||
response = incoming;
|
||||
if (settled) {
|
||||
incoming.destroy();
|
||||
return;
|
||||
}
|
||||
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
return;
|
||||
}
|
||||
let bytes = 0;
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_PUSH_RESPONSE_BYTES)
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
});
|
||||
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('end', () => {
|
||||
const status = incoming.statusCode ?? 0;
|
||||
finish(
|
||||
status >= 200 && status < 300
|
||||
? undefined
|
||||
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', (error) =>
|
||||
finish(
|
||||
error instanceof PushEndpointPolicyError
|
||||
? error
|
||||
: new PushTransportError('Push service request failed.')
|
||||
)
|
||||
);
|
||||
req.end(details.body ?? undefined);
|
||||
} catch {
|
||||
finish(new PushTransportError('Push service request failed.'));
|
||||
}
|
||||
});
|
||||
|
||||
export const sendBoundedNotification = async (
|
||||
subscription: webPush.PushSubscription,
|
||||
payload: string
|
||||
): Promise<void> => {
|
||||
parsePushEndpoint(subscription.endpoint);
|
||||
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
|
||||
await sendPushRequest(details);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
|
||||
const targetSession = await sessions.createSession(target);
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const app = fastify({ logger: false });
|
||||
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
|
||||
app.addHook('onRequest', createApiOriginGuard(origins));
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
@@ -176,6 +180,29 @@ const postTrpc = async (
|
||||
};
|
||||
|
||||
describe('admin security over HTTP transport', () => {
|
||||
it('rejects an untrusted browser origin before a valid session can log out', async () => {
|
||||
const harness = await createHarness();
|
||||
for (const origin of ['https://untrusted.example', 'null']) {
|
||||
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
|
||||
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
|
||||
}
|
||||
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://sam.hided.net',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'x-session-token,content-type',
|
||||
},
|
||||
});
|
||||
expect(allowed.status).toBe(204);
|
||||
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
|
||||
});
|
||||
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
|
||||
const harness = await createHarness(['admin.profiles.runtime:che:default']);
|
||||
const input = { profileName: 'che:default' };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
|
||||
|
||||
describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
const prefix = `auth-budget-test:${randomUUID()}`;
|
||||
const store = new RedisAuthCounterStore(client);
|
||||
const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`);
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
|
||||
it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => {
|
||||
const attempts = await Promise.all(
|
||||
Array.from({ length: 100 }, () =>
|
||||
store.consume([
|
||||
{ key: keys[0]!, limit: 10, windowMs: 60_000 },
|
||||
{ key: keys[1]!, limit: 1000, windowMs: 60_000 },
|
||||
])
|
||||
)
|
||||
);
|
||||
expect(attempts.filter((retry) => retry === 0)).toHaveLength(10);
|
||||
expect(attempts.filter((retry) => retry > 0)).toHaveLength(90);
|
||||
expect(await client.get(keys[0]!)).toBe('10');
|
||||
expect(await client.get(keys[1]!)).toBe('10');
|
||||
expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => {
|
||||
const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }];
|
||||
expect(await store.consume(counters)).toBe(0);
|
||||
const before = await client.pTTL(keys[2]!);
|
||||
expect(await store.consume(counters)).toBeGreaterThan(0);
|
||||
expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before);
|
||||
await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0);
|
||||
expect(await store.consume(counters)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => {
|
||||
await client.set(keys[3]!, '10');
|
||||
await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
|
||||
describe('authentication attempt and resource budgets', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shares a login subject across clients without exposing account or address in keys', async () => {
|
||||
const store = new InMemoryAuthCounterStore();
|
||||
const consume = vi.spyOn(store, 'consume');
|
||||
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
|
||||
for (let i = 0; i < 10; i++)
|
||||
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
|
||||
const work = vi.fn(async () => 'must not run');
|
||||
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
|
||||
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
|
||||
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
|
||||
'ok'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not extend a blocked account window and admits it after expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
|
||||
retryAfterSeconds: 1,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
|
||||
'recovered'
|
||||
);
|
||||
});
|
||||
|
||||
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
|
||||
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
});
|
||||
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
|
||||
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds expensive work and releases capacity after resolver failures', async () => {
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const work = vi.fn(async () => {
|
||||
await pending;
|
||||
throw new Error('fixture resolver failure');
|
||||
});
|
||||
const running = Array.from({ length: 4 }, () =>
|
||||
budget.run('auth.login', 'client', undefined, work).catch(() => null)
|
||||
);
|
||||
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
|
||||
const rejected = vi.fn(async () => null);
|
||||
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
|
||||
AuthBudgetError
|
||||
);
|
||||
expect(rejected).not.toHaveBeenCalled();
|
||||
release();
|
||||
await Promise.all(running);
|
||||
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
|
||||
vi.useFakeTimers();
|
||||
let release!: (retry: number) => void;
|
||||
const waiting = new Promise<number>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const store = { consume: vi.fn(() => waiting) };
|
||||
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
|
||||
const work = vi.fn(async () => 'ok');
|
||||
const requests = Array.from({ length: 32 }, () =>
|
||||
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
expect(
|
||||
(await Promise.all(requests)).every(
|
||||
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
|
||||
)
|
||||
).toBe(true);
|
||||
const actorLookup = vi.fn(async () => undefined);
|
||||
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
expect(actorLookup).not.toHaveBeenCalled();
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
expect(store.consume).toHaveBeenCalledTimes(32);
|
||||
release(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed before credential work when the counter backend errors or stalls', async () => {
|
||||
const work = vi.fn(async () => null);
|
||||
const failing = new AuthAttemptBudget(
|
||||
{
|
||||
consume: async () => {
|
||||
throw new Error('private-backend-error');
|
||||
},
|
||||
},
|
||||
'fixture',
|
||||
'secret'
|
||||
);
|
||||
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
|
||||
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
expect(await result).toMatchObject({ reason: 'unavailable' });
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { constants, publicEncrypt, randomUUID } from 'node:crypto';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
|
||||
|
||||
describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
const prefix = `auth-budget-http-test:${randomUUID()}`;
|
||||
const envelope = createPasswordEnvelopeService();
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
const keys: string[] = [];
|
||||
for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page);
|
||||
if (keys.length) await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
const credential = (password: string) => {
|
||||
const key = envelope.getPublicKey();
|
||||
return {
|
||||
keyId: key.keyId,
|
||||
ciphertext: publicEncrypt(
|
||||
{ key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||
Buffer.from(password)
|
||||
).toString('base64'),
|
||||
};
|
||||
};
|
||||
const fixture = async (available = true) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
await users.createUser({ username: 'target-user', password: 'correct-password' });
|
||||
await users.createUser({ username: 'other-user', password: 'correct-password' });
|
||||
const requestIps = new Set<string>();
|
||||
const peer = (request: FastifyRequest): string => {
|
||||
requestIps.add(request.ip);
|
||||
return request.ip;
|
||||
};
|
||||
const verify = vi.spyOn(users, 'verifyPassword');
|
||||
const open = vi.fn(envelope.open);
|
||||
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 });
|
||||
const authBudget = new AuthAttemptBudget(
|
||||
new RedisAuthCounterStore(available ? client : client.duplicate()),
|
||||
`${prefix}:${randomUUID()}`,
|
||||
'synthetic-budget-key'
|
||||
);
|
||||
const app = fastify({ trustProxy: false });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: ({ req }: { req: FastifyRequest }) =>
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
sessions,
|
||||
passwordEnvelope: { ...envelope, open },
|
||||
authBudget,
|
||||
requestIp: peer(req),
|
||||
requestHeaders: req.headers,
|
||||
flushPublisher: { publishUserFlush: async () => {} },
|
||||
gameTokenSecret: 'synthetic-game-key',
|
||||
gameSessionTtlSeconds: 600,
|
||||
kakaoClient: {} as never,
|
||||
oauthSessions: {} as never,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: true,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: 7,
|
||||
profiles: {} as never,
|
||||
orchestrator: {} as never,
|
||||
profileStatus: {} as never,
|
||||
prisma: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const post = (operation: string, input: unknown, headers: Record<string, string> = {}) =>
|
||||
fetch(`${address}/trpc/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { app, address, post, users, sessions, verify, open, requestIps };
|
||||
};
|
||||
|
||||
it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const response = await f.post(
|
||||
'auth.login',
|
||||
{ username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') },
|
||||
{ 'x-forwarded-for': `198.51.100.${i + 1}` }
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
expect(f.requestIps.size).toBe(1);
|
||||
expect(f.requestIps.has('127.0.0.1')).toBe(true);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
const denied = await f.post(
|
||||
'auth.login',
|
||||
{ username: 'target-user', credential: credential('correct-password') },
|
||||
{ 'x-forwarded-for': '203.0.113.99' }
|
||||
);
|
||||
expect(denied.status).toBe(429);
|
||||
expect(JSON.stringify(await denied.json())).not.toContain('stack');
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(200);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const statuses: number[] = [];
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const input = Object.fromEntries(
|
||||
Array.from({ length: 3 }, (_value, i) => [
|
||||
String(i),
|
||||
{ username: 'target-user', credential: credential('wrong-password') },
|
||||
])
|
||||
);
|
||||
const response = await f.post('auth.login,auth.login,auth.login?batch=1', input);
|
||||
const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>;
|
||||
statuses.push(...body.map((entry) => entry.error.data.httpStatus));
|
||||
}
|
||||
expect(statuses.filter((status) => status === 401)).toHaveLength(10);
|
||||
expect(statuses.filter((status) => status === 429)).toHaveLength(2);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shares the password/deletion subject across sessions of the same authenticated actor', async () => {
|
||||
const f = await fixture();
|
||||
const user = await f.users.findByUsername('target-user');
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const session = await f.sessions.createSession(user!);
|
||||
const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', {
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: credential('wrong-password'),
|
||||
newCredential: credential('new-password'),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
const session = await f.sessions.createSession(user!);
|
||||
expect(
|
||||
(
|
||||
await f.post('account.scheduleDeletion', {
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: credential('correct-password'),
|
||||
})
|
||||
).status
|
||||
).toBe(429);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined();
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on unavailable protection while keeping logout independent of its counters', async () => {
|
||||
const f = await fixture(false);
|
||||
try {
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(503);
|
||||
expect(f.open).not.toHaveBeenCalled();
|
||||
expect(f.verify).not.toHaveBeenCalled();
|
||||
const user = await f.users.findByUsername('target-user');
|
||||
const session = await f.sessions.createSession(user!);
|
||||
expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200);
|
||||
expect(await f.sessions.getSession(session.sessionToken)).toBeNull();
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status
|
||||
).toBe(503);
|
||||
const malformed = await fetch(`${f.address}/trpc/auth.login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{',
|
||||
});
|
||||
expect(malformed.status).toBe(400);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => {
|
||||
const f = await fixture();
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.verify.mockImplementation(async () => {
|
||||
await waiting;
|
||||
return false;
|
||||
});
|
||||
const input = { username: 'target-user', credential: credential('wrong-password') };
|
||||
const pending = Array.from({ length: 4 }, () => f.post('auth.login', input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4));
|
||||
expect((await f.post('auth.login', input)).status).toBe(429);
|
||||
expect(f.open).toHaveBeenCalledTimes(4);
|
||||
release();
|
||||
expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]);
|
||||
f.verify.mockRestore();
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(200);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled(pending);
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1242,6 +1242,59 @@ describe('gateway auth flow', () => {
|
||||
expect(validated?.user.username).toBe('tester');
|
||||
});
|
||||
|
||||
it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' });
|
||||
const parent = await sessions.createSession(user);
|
||||
const issuedAt = new Date().toISOString();
|
||||
vi.useFakeTimers({ now: new Date(issuedAt) });
|
||||
const original = users.findById.bind(users);
|
||||
const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => {
|
||||
const snapshot = await original(id);
|
||||
vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot.
|
||||
return snapshot;
|
||||
});
|
||||
try {
|
||||
const result = await caller.auth.issueGameSession({
|
||||
sessionToken: parent.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt);
|
||||
} finally {
|
||||
find.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
|
||||
const parent = await sessions.createSession(user);
|
||||
const issuedAt = new Date().toISOString();
|
||||
vi.useFakeTimers({ now: new Date(issuedAt) });
|
||||
const create = vi.spyOn(sessions, 'createGameSession');
|
||||
const original = sessions.createGameSession.bind(sessions);
|
||||
create.mockImplementationOnce(async (...args) => {
|
||||
// Snapshot admitted before the subsequent logout/flush instant.
|
||||
const game = await original(...args);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await sessions.revokeSession(parent.sessionToken);
|
||||
return game;
|
||||
});
|
||||
try {
|
||||
const issued = await caller.auth.issueGameSession({
|
||||
sessionToken: parent.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
|
||||
expect(payload?.issuedAt).toBe(issuedAt);
|
||||
expect(payload?.expiresAt).toBe(new Date(Date.parse(issuedAt) + 600_000).toISOString());
|
||||
} finally {
|
||||
create.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the migrated member number inside the encrypted game identity', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
@@ -1474,6 +1527,46 @@ describe('account self service', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds pending icon uploads without changing the owned library limit', async () => {
|
||||
const { caller, users, sessions, userIconUpload } = buildCaller();
|
||||
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const input = {
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
};
|
||||
const pending: Array<() => void> = [];
|
||||
userIconUpload.upload.mockImplementation(
|
||||
({ filename }) =>
|
||||
new Promise((resolve) => {
|
||||
pending.push(() =>
|
||||
resolve({
|
||||
picture: `users/core2026/${filename}`,
|
||||
publicUrl: `https://image.example/${filename}`,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
|
||||
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
} finally {
|
||||
pending.forEach((done) => done());
|
||||
await Promise.all(admitted);
|
||||
}
|
||||
expect(await users.listIcons(user.id)).toHaveLength(4);
|
||||
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
|
||||
picture: `users/core2026/${filename}`,
|
||||
publicUrl: `https://image.example/${filename}`,
|
||||
}));
|
||||
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
|
||||
expect(await users.listIcons(user.id)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js';
|
||||
|
||||
const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
|
||||
describe.skipIf(!url)('persistent Gateway flush over real Redis', () => {
|
||||
const client = createClient({ url });
|
||||
const channel = `revocation-publisher-test:${randomUUID()}:flush`;
|
||||
const publisher = new RedisGatewayFlushPublisher(client, channel);
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 }))
|
||||
if (keys.length) await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
it('initializes once and preserves the original cutoff across publisher restarts', async () => {
|
||||
await publisher.initialize();
|
||||
const baseline = await client.get(buildSessionRevocationBaselineKey(channel));
|
||||
expect(Number(baseline)).toBeGreaterThan(0);
|
||||
await new RedisGatewayFlushPublisher(client, channel).initialize();
|
||||
expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline);
|
||||
expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1);
|
||||
});
|
||||
it('persists a revocation when there are zero subscribers', async () => {
|
||||
const userId = randomUUID();
|
||||
await publisher.publishUserFlush(userId, 'logout');
|
||||
expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0);
|
||||
expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1);
|
||||
});
|
||||
it('makes the watermark readable before delivering flush metadata', async () => {
|
||||
const subscriber = client.duplicate();
|
||||
await subscriber.connect();
|
||||
const userId = randomUUID();
|
||||
let delivered: { event: Record<string, unknown>; persisted: string | null } | undefined;
|
||||
await subscriber.subscribe(channel, async (raw) => {
|
||||
delivered = {
|
||||
event: JSON.parse(raw) as Record<string, unknown>,
|
||||
persisted: await client.get(buildUserSessionRevocationKey(channel, userId)),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await publisher.publishUserFlush(userId, 'identity-updated', {
|
||||
displayName: 'fixture',
|
||||
identityRevision: 'revision-1',
|
||||
});
|
||||
await vi.waitFor(() => expect(delivered).toBeDefined());
|
||||
expect(delivered!.event).toMatchObject({
|
||||
userId,
|
||||
reason: 'identity-updated',
|
||||
displayName: 'fixture',
|
||||
identityRevision: 'revision-1',
|
||||
});
|
||||
expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string));
|
||||
} finally {
|
||||
await subscriber.unsubscribe(channel);
|
||||
await subscriber.quit();
|
||||
}
|
||||
});
|
||||
it('never moves a newer watermark backwards under late or concurrent flushes', async () => {
|
||||
const userId = randomUUID();
|
||||
const future = String(Date.now() + 60_000);
|
||||
await client.set(buildUserSessionRevocationKey(channel, userId), future);
|
||||
await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent')));
|
||||
expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future);
|
||||
});
|
||||
it('fails closed for an unavailable client instead of publishing without storage', async () => {
|
||||
const offline = client.duplicate();
|
||||
const closed = new RedisGatewayFlushPublisher(offline, channel);
|
||||
await expect(closed.initialize()).rejects.toThrow('unavailable');
|
||||
await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { RedisGatewaySessionService } from '../src/auth/redisSessionService.js';
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
|
||||
const limit = 256;
|
||||
const user = async () => {
|
||||
const users = createInMemoryUserRepository();
|
||||
return users.createUser({ username: `session-${randomUUID()}`, password: 'synthetic-test-password' });
|
||||
};
|
||||
|
||||
describe.skipIf(!redisUrl)('game session lifecycle over real Redis', () => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
const prefix = `game-session-security-test:${randomUUID()}`;
|
||||
const store = new RedisGatewaySessionService(client, {
|
||||
keyPrefix: prefix,
|
||||
sessionTtlSeconds: 600,
|
||||
gameSessionTtlSeconds: 300,
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
for await (const keys of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) {
|
||||
if (keys.length) await client.del(keys);
|
||||
}
|
||||
await client.quit();
|
||||
});
|
||||
const keys = (token: string) => ({
|
||||
parent: `${prefix}:session:${token}`,
|
||||
games: `${prefix}:session-games:${token}`,
|
||||
});
|
||||
|
||||
const httpFixture = async () => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const first = await users.createUser({ username: `first-${randomUUID()}`, password: 'synthetic-password' });
|
||||
const second = await users.createUser({ username: `second-${randomUUID()}`, password: 'synthetic-password' });
|
||||
const parents = await Promise.all([
|
||||
store.createSession(first),
|
||||
store.createSession(first),
|
||||
store.createSession(second),
|
||||
]);
|
||||
const profiles = { getProfile: vi.fn(async () => null) };
|
||||
const authBudget = new AuthAttemptBudget(
|
||||
new RedisAuthCounterStore(client),
|
||||
`${prefix}:${randomUUID()}`,
|
||||
'synthetic-rate-key'
|
||||
);
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const app = fastify({ trustProxy: false });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: ({ req }: { req: FastifyRequest }) =>
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
sessions: store,
|
||||
passwordEnvelope,
|
||||
authBudget,
|
||||
requestIp: req.ip,
|
||||
requestHeaders: req.headers,
|
||||
flushPublisher: { publishUserFlush: async () => {} },
|
||||
gameTokenSecret: 'synthetic-game-key',
|
||||
gameSessionTtlSeconds: 300,
|
||||
kakaoClient: {} as never,
|
||||
oauthSessions: {} as never,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: true,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: 7,
|
||||
profiles: profiles as never,
|
||||
orchestrator: {} as never,
|
||||
profileStatus: {} as never,
|
||||
prisma: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const post = (operation: string, input: unknown) =>
|
||||
fetch(`${address}/trpc/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const issue = (index: number) =>
|
||||
post('auth.issueGameSession', { sessionToken: parents[index]!.sessionToken, profile: 'che:default' });
|
||||
return { app, post, issue, parents, profiles };
|
||||
};
|
||||
|
||||
it('limits real HTTP issuance by account across login sessions before profile/icon reads', async () => {
|
||||
const f = await httpFixture();
|
||||
try {
|
||||
for (let i = 0; i < 32; i++) expect((await f.issue(i % 2)).status).toBe(200);
|
||||
const denied = await f.issue(1);
|
||||
expect(denied.status).toBe(429);
|
||||
expect(f.profiles.getProfile).toHaveBeenCalledTimes(32);
|
||||
expect(JSON.stringify(await denied.json())).not.toContain('stack');
|
||||
expect((await f.issue(2)).status).toBe(200);
|
||||
expect(
|
||||
(await f.post('auth.issueGameSession', { sessionToken: 'unknown', profile: 'che:default' })).status
|
||||
).toBe(401);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the live record cap to HTTP 429 without invalidating existing tokens', async () => {
|
||||
const f = await httpFixture();
|
||||
const parent = f.parents[0]!;
|
||||
try {
|
||||
const first = await store.createGameSession(parent.sessionToken, 'che:default');
|
||||
for (let i = 1; i < limit; i++) await store.createGameSession(parent.sessionToken, 'che:default');
|
||||
expect((await f.issue(0)).status).toBe(429);
|
||||
expect(await store.getGameSession('che:default', first!.gameToken)).toEqual(first);
|
||||
expect((await f.post('auth.logout', { sessionToken: parent.sessionToken })).status).toBe(200);
|
||||
expect((await f.issue(0)).status).toBe(401);
|
||||
expect(await client.sCard(keys(parent.sessionToken).games)).toBe(0);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves game records when explicitly revoking only the parent', async () => {
|
||||
const parent = await store.createSession(await user());
|
||||
const game = await store.createGameSession(parent.sessionToken, 'che:default');
|
||||
await store.revokeSession(parent.sessionToken, { revokeGames: false });
|
||||
expect(await store.getSession(parent.sessionToken)).toBeNull();
|
||||
expect(await store.getGameSession('che:default', game!.gameToken)).toEqual(game);
|
||||
});
|
||||
|
||||
it('atomically admits exactly 256 live records across concurrent profile requests', async () => {
|
||||
const session = await store.createSession(await user());
|
||||
const outcomes = await Promise.allSettled(
|
||||
Array.from({ length: limit + 32 }, (_, i) =>
|
||||
store.createGameSession(session.sessionToken, i % 2 ? 'che:default' : 'hwe:default')
|
||||
)
|
||||
);
|
||||
expect(outcomes.filter((x) => x.status === 'fulfilled' && x.value)).toHaveLength(limit);
|
||||
expect(outcomes.filter((x) => x.status === 'rejected')).toHaveLength(32);
|
||||
expect(await client.sCard(keys(session.sessionToken).games)).toBe(limit);
|
||||
});
|
||||
|
||||
it('removes expired membership before admitting a new game session', async () => {
|
||||
const session = await store.createSession(await user());
|
||||
const key = keys(session.sessionToken).games;
|
||||
const expiredKeys = Array.from({ length: limit }, (_, i) => `${prefix}:game-session:che:default:expired-${i}`);
|
||||
await client.sAdd(key, expiredKeys);
|
||||
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
|
||||
expect(await client.sCard(key)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not revive a parent revoked after its snapshot was read', async () => {
|
||||
const session = await store.createSession(await user());
|
||||
const get = vi.spyOn(store, 'getSession');
|
||||
get.mockImplementationOnce(async () => {
|
||||
await store.revokeSession(session.sessionToken);
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
|
||||
expect(await client.exists(keys(session.sessionToken).games)).toBe(0);
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves independent game TTL without resetting the index to a fresh parent TTL', async () => {
|
||||
const session = await store.createSession(await user());
|
||||
const { parent, games } = keys(session.sessionToken);
|
||||
await client.pExpire(parent, 1000);
|
||||
const game = await store.createGameSession(session.sessionToken, 'che:default');
|
||||
expect(game).not.toBeNull();
|
||||
expect(await client.pTTL(games)).toBeGreaterThan(295_000);
|
||||
expect(await client.pTTL(games)).toBeLessThanOrEqual(300_000);
|
||||
});
|
||||
|
||||
it('revokes existing oversized indexes in bounded batches while preserving other parents', async () => {
|
||||
const session = await store.createSession(await user());
|
||||
const other = await store.createSession(await user());
|
||||
const gameKeys = Array.from({ length: limit + 9 }, (_, i) => `${prefix}:game-session:che:default:legacy-${i}`);
|
||||
const transaction = client.multi();
|
||||
for (const key of gameKeys) transaction.set(key, '{}', { EX: 300 });
|
||||
transaction.sAdd(keys(session.sessionToken).games, gameKeys);
|
||||
await transaction.exec();
|
||||
await expect(store.createGameSession(session.sessionToken, 'che:default')).rejects.toThrow(
|
||||
'Game session limit'
|
||||
);
|
||||
await store.revokeSession(session.sessionToken);
|
||||
expect(
|
||||
await client.exists([keys(session.sessionToken).parent, keys(session.sessionToken).games, ...gameKeys])
|
||||
).toBe(0);
|
||||
expect(await store.getSession(other.sessionToken)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-memory game session lifecycle', () => {
|
||||
it('enforces the same live cap and reclaims expired records', async () => {
|
||||
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 1 });
|
||||
const session = await store.createSession(await user());
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
for (let i = 0; i < limit; i++)
|
||||
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
|
||||
await expect(store.createGameSession(session.sessionToken, 'hwe:default')).rejects.toThrow(
|
||||
'Game session limit'
|
||||
);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(await store.createGameSession(session.sessionToken, 'hwe:default')).not.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it('does not issue after a concurrent revoke', async () => {
|
||||
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 300 });
|
||||
const session = await store.createSession(await user());
|
||||
const get = vi.spyOn(store, 'getSession').mockImplementationOnce(async () => {
|
||||
await store.revokeSession(session.sessionToken);
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import fastify from 'fastify';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { router as gatewayRouter, procedure as gatewayProcedure } from '../src/trpc.js';
|
||||
|
||||
const internalFailure = () => {
|
||||
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||
};
|
||||
const badInput = () => {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '잘못된 입력입니다.',
|
||||
cause: new Error('synthetic-private-cause'),
|
||||
});
|
||||
};
|
||||
|
||||
describe('public HTTP error privacy', () => {
|
||||
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/gateway',
|
||||
trpcOptions: {
|
||||
router: gatewayRouter({
|
||||
internal: gatewayProcedure.query(internalFailure),
|
||||
invalid: gatewayProcedure.query(badInput),
|
||||
}),
|
||||
createContext: () => ({}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
for (const prefix of ['gateway']) {
|
||||
for (const procedure of ['internal', 'invalid']) {
|
||||
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||
const body = (await response.json()) as {
|
||||
error: { message: string; data: { code: string; stack?: string } };
|
||||
};
|
||||
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||
expect(body.error.message).toBe(
|
||||
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||
);
|
||||
expect(body.error.data.stack).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
|
||||
await expect(createClient().signup('access-token')).resolves.toEqual({
|
||||
alreadyRegistered: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://kapi.example.test/v1/user/signup'),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues to reject unrelated Kakao signup errors', async () => {
|
||||
@@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(createClient().signup('access-token')).rejects.toThrow(
|
||||
'Kakao signup error: {"msg":"invalid request","code":-201}'
|
||||
);
|
||||
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
|
||||
|
||||
describe('bounded Kakao provider transport', () => {
|
||||
const servers: Server[] = [];
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => {
|
||||
const server = createServer((_request, response) => respond(response));
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Fixture provider listener failed.');
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
return new KakaoOAuthClient({
|
||||
restKey: 'synthetic-key',
|
||||
redirectUri: `${origin}/callback`,
|
||||
apiHost: origin,
|
||||
oauthHost: origin,
|
||||
requestTimeoutMs,
|
||||
});
|
||||
};
|
||||
|
||||
it('retains successful token/account responses and documented recovery responses', async () => {
|
||||
const client = await provider((res) =>
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 42,
|
||||
access_token: 'synthetic-token',
|
||||
expires_in: 3600,
|
||||
kakao_account: {
|
||||
has_email: true,
|
||||
email: 'fixture@example.test',
|
||||
is_email_valid: true,
|
||||
is_email_verified: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(await client.exchangeCode('synthetic-code')).toMatchObject({
|
||||
accessToken: 'synthetic-token',
|
||||
accessTokenExpiresIn: 3600,
|
||||
});
|
||||
expect(await client.getMe('synthetic-token')).toMatchObject({
|
||||
id: '42',
|
||||
kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts both header and response-body stalls within the configured deadline', async () => {
|
||||
const silent = await provider(() => {}, 100);
|
||||
await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
const partial = await provider((res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.write('{');
|
||||
}, 100);
|
||||
await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
});
|
||||
|
||||
it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => {
|
||||
const oversized = await provider((res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' });
|
||||
res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) })));
|
||||
});
|
||||
await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data'));
|
||||
await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
});
|
||||
|
||||
it('does not follow redirects or expose provider error details', async () => {
|
||||
let destinationRequests = 0;
|
||||
const destination = await provider((res) => {
|
||||
destinationRequests += 1;
|
||||
res.end('{}');
|
||||
});
|
||||
const destinationUrl = new URL(destination.buildAuthUrl('state', []));
|
||||
const redirect = await provider((res) => {
|
||||
res.writeHead(302, { location: destinationUrl.origin });
|
||||
res.end();
|
||||
});
|
||||
await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
expect(destinationRequests).toBe(0);
|
||||
const failure = await provider((res) => {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 }));
|
||||
});
|
||||
await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
RemoteBuildRunner,
|
||||
@@ -34,12 +37,24 @@ describe('sanitizeReleaseBuildEnv', () => {
|
||||
});
|
||||
|
||||
describe('RemoteBuildRunner', () => {
|
||||
let directory: string;
|
||||
let tokenFile: string;
|
||||
beforeAll(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builder-token-test-'));
|
||||
tokenFile = path.join(directory, 'token');
|
||||
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
it('streams progress and returns the builder result without sending secrets', async () => {
|
||||
const messages = [
|
||||
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
|
||||
{ result: { ok: true, exitCode: 0, output: 'building' } },
|
||||
];
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
|
||||
expect(init?.redirect).toBe('error');
|
||||
const request = JSON.parse(String(init?.body)) as {
|
||||
commands: Array<{ env: Record<string, string> }>;
|
||||
};
|
||||
@@ -49,7 +64,7 @@ describe('RemoteBuildRunner', () => {
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
const progress: BuildProgressEvent[] = [];
|
||||
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
|
||||
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl, { tokenFile }).run(
|
||||
[
|
||||
{
|
||||
command: 'pnpm',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { RemoteBuildRunner } from '../src/orchestrator/buildRunner.js';
|
||||
|
||||
describe('release builder authenticated HTTP and bounded responses', () => {
|
||||
let directory: string;
|
||||
let tokenFile: string;
|
||||
const servers: Server[] = [];
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-build-http-'));
|
||||
tokenFile = path.join(directory, 'token');
|
||||
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const provider = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
|
||||
const server = createServer(handler);
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Fixture listener unavailable.');
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
};
|
||||
const commands = [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/srv/core/repository' }];
|
||||
it('sends the file token only as a header and preserves successful stream results', async () => {
|
||||
let received = '';
|
||||
const endpoint = await provider((req, res) => {
|
||||
expect(req.headers['x-release-builder-token']).toBe('t'.repeat(64));
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
received += chunk.toString();
|
||||
});
|
||||
req.on('end', () => res.end(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: 'done' } })}\n`));
|
||||
});
|
||||
expect(await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).toMatchObject({
|
||||
ok: true,
|
||||
output: 'done',
|
||||
});
|
||||
expect(received).not.toContain('t'.repeat(64));
|
||||
});
|
||||
it('fails closed without a valid credential before sending a request', async () => {
|
||||
let requests = 0;
|
||||
const endpoint = await provider((_req, res) => {
|
||||
requests += 1;
|
||||
res.end();
|
||||
});
|
||||
const result = await new RemoteBuildRunner(endpoint).run(commands);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(requests).toBe(0);
|
||||
expect(result.output).not.toContain(directory);
|
||||
});
|
||||
it('rejects malformed and oversized progress without exposing upstream error payloads', async () => {
|
||||
for (const body of [
|
||||
'{"event":{"type":"OUTPUT","stream":"stdout","message":42}}\n',
|
||||
'x'.repeat(512 * 1024 + 1),
|
||||
'null\n',
|
||||
'{"result":{"ok":true,"exitCode":2,"output":""}}\n',
|
||||
]) {
|
||||
const endpoint = await provider((_req, res) => res.end(body));
|
||||
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
|
||||
}
|
||||
const endpoint = await provider((_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end('synthetic-sensitive-detail'.repeat(100_000));
|
||||
});
|
||||
const result = await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands);
|
||||
expect(result.output).toBe('Release builder returned HTTP 500.');
|
||||
const rejected = await provider((_req, res) =>
|
||||
res.end(JSON.stringify({ error: 'synthetic-sensitive-detail' }) + '\n')
|
||||
);
|
||||
expect((await new RemoteBuildRunner(rejected, fetch, { tokenFile }).run(commands)).output).toBe(
|
||||
'Release builder rejected the build request.'
|
||||
);
|
||||
});
|
||||
it('does not forward the token to redirect destinations', async () => {
|
||||
let requests = 0;
|
||||
const destination = await provider((_req, res) => {
|
||||
requests += 1;
|
||||
res.end();
|
||||
});
|
||||
const endpoint = await provider((_req, res) => {
|
||||
res.writeHead(307, { location: destination });
|
||||
res.end();
|
||||
});
|
||||
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
|
||||
expect(requests).toBe(0);
|
||||
});
|
||||
it('bounds header/body stalls and preserves explicit operator cancellation', async () => {
|
||||
const silent = await provider(() => {});
|
||||
expect((await new RemoteBuildRunner(silent, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
|
||||
false
|
||||
);
|
||||
const partial = await provider((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.write('{');
|
||||
});
|
||||
expect((await new RemoteBuildRunner(partial, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
|
||||
false
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = new RemoteBuildRunner(partial, fetch, { tokenFile }).run(commands, undefined, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({ ok: false, aborted: true, output: 'Build cancelled by operator.' });
|
||||
});
|
||||
});
|
||||
@@ -40,9 +40,7 @@ describe('remote user icon store', () => {
|
||||
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(
|
||||
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
|
||||
)
|
||||
.update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`)
|
||||
.digest('hex');
|
||||
expect(headers['x-image-signature']).toBe(expected);
|
||||
});
|
||||
@@ -58,4 +56,15 @@ describe('remote user icon store', () => {
|
||||
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
|
||||
).rejects.toThrow('HTTP 401');
|
||||
});
|
||||
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||
const filename = `${'d'.repeat(32)}.png`;
|
||||
const store = new RemoteUserIconStore(
|
||||
'https://image.example',
|
||||
'https://image.example/public',
|
||||
'synthetic-secret',
|
||||
async () =>
|
||||
new Response(JSON.stringify({ path: `icons/users/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||
);
|
||||
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as pushSender from '../src/webPush/sendNotification.js';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import webPush from 'web-push';
|
||||
|
||||
@@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => {
|
||||
notificationId: notification.id,
|
||||
},
|
||||
});
|
||||
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 });
|
||||
const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 });
|
||||
try {
|
||||
coordinator.start();
|
||||
await vi.waitFor(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Agent, request as httpsRequest } from 'node:https';
|
||||
import { createPublicPushLookup, PushEndpointPolicyError } from '../src/webPush/endpointPolicy.js';
|
||||
import { createServer, request, type Server } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sendPushRequest } from '../src/webPush/sendNotification.js';
|
||||
|
||||
const details = {
|
||||
endpoint: 'https://push.example/subscription',
|
||||
method: 'POST' as const,
|
||||
headers: {},
|
||||
body: Buffer.from('encrypted'),
|
||||
};
|
||||
const listen = async (server: Server) => {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return (server.address() as { port: number }).port;
|
||||
};
|
||||
const close = (server: Server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
const transport =
|
||||
(port: number): Parameters<typeof sendPushRequest>[1] =>
|
||||
(options, callback) =>
|
||||
request({ ...options, protocol: 'http:', hostname: '127.0.0.1', port, agent: undefined }, callback);
|
||||
|
||||
describe('bounded WebPush response transport', () => {
|
||||
it('accepts normal success and preserves terminal HTTP status without retaining sensitive response bodies', async () => {
|
||||
let status = 201;
|
||||
const server = createServer((_, res) => {
|
||||
res.writeHead(status);
|
||||
res.end('private upstream details');
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).resolves.toBeUndefined();
|
||||
status = 410;
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({
|
||||
statusCode: 410,
|
||||
message: 'Push service returned HTTP 410.',
|
||||
});
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it.each([true, false])('rejects oversized response with declared length %s', async (declared) => {
|
||||
const server = createServer((_, res) => {
|
||||
if (declared) res.setHeader('content-length', 20_000);
|
||||
res.end(Buffer.alloc(20_000));
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toThrow('size limit');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it('ends a continuously dripping response on the whole exchange deadline', async () => {
|
||||
const server = createServer((_, res) => {
|
||||
const timer = setInterval(() => res.write('x'), 10);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port), 100)).rejects.toThrow('timed out');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it('preserves terminal public-address policy failures from actual socket lookup', async () => {
|
||||
const agent = new Agent({ lookup: createPublicPushLookup(async () => [{ address: '127.0.0.1', family: 4 }]) });
|
||||
try {
|
||||
await expect(
|
||||
sendPushRequest(details, (options, callback) => httpsRequest({ ...options, agent }, callback))
|
||||
).rejects.toBeInstanceOf(PushEndpointPolicyError);
|
||||
} finally {
|
||||
agent.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not follow redirects', async () => {
|
||||
let calls = 0;
|
||||
const server = createServer((_, res) => {
|
||||
calls++;
|
||||
res.writeHead(307, { location: 'https://elsewhere.example' });
|
||||
res.end();
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({ statusCode: 307 });
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,12 @@ const main = async (): Promise<void> => {
|
||||
baseEnv: config.baseEnv,
|
||||
});
|
||||
const migrationRunner = new PnpmBuildRunner();
|
||||
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner);
|
||||
const releaseBuildRunner = createReleaseBuildRunner(
|
||||
config.releaseBuilderUrl,
|
||||
migrationRunner,
|
||||
fetch,
|
||||
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
|
||||
);
|
||||
const processManager = new Pm2ProcessManager();
|
||||
const controller = new GatewayReleaseController(
|
||||
repository,
|
||||
|
||||
@@ -160,7 +160,12 @@ export class GatewayReleaseController {
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly fetchImpl: typeof fetch = fetch
|
||||
) {
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner, fetchImpl);
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(
|
||||
config.releaseBuilderUrl,
|
||||
migrationRunner,
|
||||
fetchImpl,
|
||||
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
|
||||
);
|
||||
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
|
||||
}
|
||||
|
||||
@@ -509,7 +514,11 @@ export class GatewayReleaseController {
|
||||
}
|
||||
|
||||
private async waitForReadiness(operationId: string): Promise<void> {
|
||||
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
|
||||
await this.appendLog(
|
||||
operationId,
|
||||
'readiness',
|
||||
'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'
|
||||
);
|
||||
const deadline = performance.now() + this.config.readinessTimeoutMs;
|
||||
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
|
||||
const frontendUrl =
|
||||
@@ -524,10 +533,7 @@ export class GatewayReleaseController {
|
||||
);
|
||||
while (performance.now() < deadline) {
|
||||
try {
|
||||
const [api, frontend] = await Promise.all([
|
||||
this.fetchImpl(apiUrl),
|
||||
this.fetchImpl(frontendUrl),
|
||||
]);
|
||||
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
|
||||
const processes = await this.processManager.list();
|
||||
const expected = processes.filter((process) => expectedNames.includes(process.name));
|
||||
const safe = expected.filter(
|
||||
|
||||
@@ -447,6 +447,8 @@ describe('GatewayReleaseController', () => {
|
||||
|
||||
it('runs release builds remotely while keeping Gateway migration on the runtime runner', async () => {
|
||||
const workspace = await createReleaseWorkspace();
|
||||
const tokenFile = path.join(workspace, 'builder-token.fixture');
|
||||
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
|
||||
const harness = createRepository();
|
||||
const localCommandGroups: string[][] = [];
|
||||
const remoteRequests: Array<{ commands: Array<{ args: string[] }> }> = [];
|
||||
@@ -481,10 +483,15 @@ describe('GatewayReleaseController', () => {
|
||||
running.delete(name);
|
||||
},
|
||||
},
|
||||
{ ...config, releaseBuilderUrl: 'http://builder:15100' },
|
||||
{
|
||||
...config,
|
||||
releaseBuilderUrl: 'http://builder:15100',
|
||||
baseEnv: { ...config.baseEnv, RELEASE_BUILDER_TOKEN_FILE: tokenFile },
|
||||
},
|
||||
() => new Date('2026-08-01T00:00:00.000Z'),
|
||||
async (input, init) => {
|
||||
if (String(input) === 'http://builder:15100/v1/builds') {
|
||||
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
|
||||
remoteRequests.push(JSON.parse(String(init?.body)) as { commands: Array<{ args: string[] }> });
|
||||
return new Response(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: '' } })}\n`, {
|
||||
status: 200,
|
||||
|
||||
Reference in New Issue
Block a user