SSE 헤더 인증과 연결 수명 검증 및 HTTP 오류 정보 보호
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
}
|
||||
+74
-76
@@ -1,3 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -32,6 +33,8 @@ 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 { formatSseFrame } from './realtime/sse.js';
|
||||
import {
|
||||
shouldForwardRealtimeEvent,
|
||||
@@ -99,11 +102,16 @@ export const createGameApiServer = async () => {
|
||||
imageUploadSecret
|
||||
);
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
logger: safeHttpLoggerOptions,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
|
||||
app.addHook('preClose', async () => {
|
||||
for (const connection of realtimeConnections) connection.close();
|
||||
});
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
@@ -150,6 +158,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) => {
|
||||
@@ -299,14 +313,24 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
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);
|
||||
const auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null;
|
||||
|
||||
if (!auth) {
|
||||
if (!auth || auth.profile !== config.profileName) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
@@ -351,88 +375,62 @@ export const createGameApiServer = async () => {
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
try {
|
||||
reply.raw.write(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: '{}',
|
||||
})
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||
resolveRealtimeAuth(tokenFromHeader)
|
||||
);
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
realtimeConnections.add(connection);
|
||||
connection.onClose(() => {
|
||||
realtimeConnections.delete(connection);
|
||||
});
|
||||
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
identities.push(nextIdentity);
|
||||
viewerIdentity = nextIdentity;
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || closed) return;
|
||||
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.
|
||||
}
|
||||
}
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: publicEvent.type,
|
||||
data: JSON.stringify(publicEvent),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A best-effort notification must not affect committed game state.
|
||||
connection.enqueue(async (currentAuth) => {
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
identities.push(nextIdentity);
|
||||
viewerIdentity = nextIdentity;
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(currentAuth, config.profileName, config.gameTokenSecret);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || connection.closed) return null;
|
||||
if (refreshGrant) {
|
||||
try {
|
||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||
} catch {
|
||||
// An unregistered grant falls back to the scored refresh path.
|
||||
}
|
||||
}
|
||||
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||
});
|
||||
});
|
||||
|
||||
connection.onClose(unsubscribe);
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending) return;
|
||||
if (heartbeatPending || connection.closed) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
connection.enqueue(async () => {
|
||||
try {
|
||||
return formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||
});
|
||||
} finally {
|
||||
heartbeatPending = false;
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
connection.onClose(() => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
});
|
||||
request.raw.once('aborted', connection.close);
|
||||
connection.onClose(() => {
|
||||
request.raw.off('aborted', connection.close);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
|
||||
@@ -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,7 +21,7 @@ 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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,4 @@
|
||||
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';
|
||||
@@ -2317,9 +2318,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 +3573,108 @@ 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 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user