게임 세션의 원자적 발급과 추적 상한 및 회수를 보강한다

This commit is contained in:
2026-10-09 05:10:05 +09:00
parent 6755f09ee7
commit eeda225429
9 changed files with 399 additions and 44 deletions
+12 -3
View File
@@ -111,10 +111,15 @@ const EXPENSIVE_ACTIONS = new Set([
'account.changePassword',
'account.scheduleDeletion',
]);
const PUBLIC_ACTIONS = new Set(['auth.passwordKey', 'auth.checkRegistrationField', 'auth.kakaoStart']);
const LIGHTWEIGHT_ACTIONS = new Set([
'auth.passwordKey',
'auth.checkRegistrationField',
'auth.kakaoStart',
'auth.issueGameSession',
]);
export const isBudgetedAuthAction = (action: string): boolean =>
EXPENSIVE_ACTIONS.has(action) || PUBLIC_ACTIONS.has(action);
EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action);
export class AuthAttemptBudget {
private active = 0;
@@ -149,7 +154,11 @@ export class AuthAttemptBudget {
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
];
if (resolvedSubject)
counters.push({ key: this.key('subject', resolvedSubject), limit: 10, windowMs: 60_000 });
counters.push({
key: this.key('subject', resolvedSubject),
limit: action === 'auth.issueGameSession' ? 32 : 10,
windowMs: 60_000,
});
return this.store.consume(counters);
};
this.pendingChecks += 1;
@@ -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;
}
+51 -29
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
import type {
GameSessionInfo,
GatewaySessionConfig,
@@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig {
keyPrefix: string;
}
interface RedisPipeline {
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
sAdd(key: string, member: string): RedisPipeline;
expire(key: string, seconds: number): RedisPipeline;
del(key: string): RedisPipeline;
exec(): Promise<unknown>;
}
interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
sMembers(key: string): Promise<string[]>;
multi(): RedisPipeline;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
del(key: string): Promise<number>;
}
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다.
const CREATE_GAME_SESSION_SCRIPT = `
local parentTtl = redis.call('PTTL', KEYS[1])
if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
local limit = tonumber(ARGV[4])
if redis.call('SCARD', KEYS[2]) > limit then return -1 end
for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do
if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end
end
if redis.call('SCARD', KEYS[2]) >= limit then return -1 end
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3])
redis.call('SADD', KEYS[2], KEYS[3])
redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000))
return 1
`;
// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다.
const REVOKE_GAME_SESSION_BATCH_SCRIPT = `
redis.call('DEL', KEYS[1])
local entries = redis.call('SPOP', KEYS[2], 64)
for _, entry in ipairs(entries) do redis.call('DEL', entry) end
return #entries
`;
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
export class RedisGatewaySessionService implements GatewaySessionService {
private readonly client: RedisClientLike;
@@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService {
options: SessionRevocationOptions = { revokeGames: true }
): Promise<void> {
const key = this.keys.sessionKey(sessionToken);
if (options.revokeGames ?? true) {
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
const games = await this.client.sMembers(gameSetKey);
if (games.length > 0) {
const pipeline = this.client.multi();
for (const entry of games) {
pipeline.del(entry);
}
pipeline.del(gameSetKey);
pipeline.del(key);
await pipeline.exec();
if (!(options.revokeGames ?? true)) {
await this.client.del(key);
return;
}
await this.client.del(gameSetKey);
// 부모 제거와 각 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;
+13 -3
View File
@@ -20,6 +20,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';
@@ -1050,7 +1051,7 @@ export const appRouter = router({
issueGameSession: procedure
.input(
z.object({
sessionToken: z.string().min(1),
sessionToken: z.string().min(1).max(256),
profile: zProfile,
})
)
@@ -1091,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(gameSession.issuedAt);
const accountIcon = resolveEffectiveAccountIcon(user);
const accountIcons = await ctx.users.listIcons(user.id);
const payload = {
+6 -2
View File
@@ -15,9 +15,13 @@ export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (path.startsWith('account.') && typeof input.sessionToken === 'string' && input.sessionToken.length <= 256) {
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 `account:${session.userId}`;
if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`;
}
return undefined;
};
+29
View File
@@ -1242,6 +1242,35 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
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({
@@ -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();
}
});
});
+26 -1
View File
@@ -578,6 +578,7 @@ batch의 항목도 각각 차감한다. 기본 창은 최초 허용 요청부터
| ----------------------------------------------------------------------- | ---------------------- |
| 정규화한 로그인 계정명, peer와 무관 | 10회/60초 |
| 같은 인증 actor의 비밀번호 변경·탈퇴, session을 바꿔도 공유 | 10회/60초 |
| 같은 계정의 게임 세션 발급, 로그인 session/profile을 바꿔도 공유 | 32회/60초 |
| 인증 시작·등록·자격 증명·OTP와 public key/가입 필드 검사, socket peer별 | 600회/60초 |
| 위 인증 procedure 전체, 같은 Gateway Redis namespace | 1200회/60초 |
| RSA/Argon2/provider 호출이 있는 인증 작업, API 프로세스별 | 동시 4개, 대기 큐 없음 |
@@ -614,10 +615,34 @@ Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, d
게임 시간·OTP 만료·기존 session lifetime·Argon2 강도와 무관하다.
이 단계는 온라인 자격 증명/가입 비용을 보호하며 established session의 일반 조회,
logout과 game-session 교환에 동일한 인증 시작 budget을 강제로 적용하지 않는다.
logout은 이 budget에서 제외한다. 게임 세션 발급은 별도 계정 counter 32회/60초를
사용하고 peer/global counter와 확인 대기 상한을 공유하며 RSA/Argon2 동시 자리에는
들어가지 않는다.
임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의
검증 결과를 실제 공개 배포 근거로 사용하지 않는다.
### 게임 세션 발급·추적 수명 (2026-10-09)
Gateway 로그인 하나가 추적하는 살아 있는 게임 세션은 모든 profile을 합쳐 256개까지
허용한다. 기존 게임 토큰을 강제로 폐기하지 않으며 초과 발급은 HTTP 429다.
발급 전에 만료된 Redis key를 추적 set에서 제거한다. 로그인별 추적 set이 이전
버전에서 이미 256개보다 크다면 전부 펼치지 않고 새 발급을 거절하며, 로그아웃 뒤
다시 로그인하거나 기존 index가 만료된 뒤 복구한다. 정상 다중 탭/여러 profile을
지원하되 발급 자료의 무제한 누적을 허용하지 않는 Core 보안 정책이다.
Redis Lua가 부모 session의 존재·snapshot 일치·만료, 정리·상한과 새 저장을 한 번에
검사한다. 로그아웃은 부모 제거와 최대 64개 게임 key/index 삭제를 원자적으로
반복하여, 발급 경합과 큰 기존 index의 일괄 응답을 방지한다. 부모만 회수하는
`revokeGames:false`는 독립 게임 TTL을 유지한다. 게임 token TTL은 여전히 발급 시점부터
`GAME_SESSION_TTL_SECONDS`이고 부모 로그인 만료가 이미 발급된 게임 token의 만료를
앞당기지는 않는다. index TTL은 남은 부모 TTL과 게임 TTL 중 큰 값이며 새 부모 TTL로
매번 초기화하지 않는다. 새로운 env key와 DB migration은 필요 없다.
암호화 게임 token의 issuedAt/expiresAt은 추적 항목의 최초 발급 시각에서 계산한다.
이후 icon/identity 조회 중 logout/flush가 겹쳐도 issuedAt을 더 늦은 시각으로
바꿔 이전 flush를 피하지 않는다. 이는 한 발급 flow의 경합 방어이며 pub/sub 누락이나
API 재시작 후 회수 유지까지 증명하는 정책은 아니다.
### Builder 요청 인증과 자원 경계 (2026-10-09)
`RELEASE_BUILDER_URL`을 설정한 원격 build는 전용 `RELEASE_BUILDER_TOKEN_FILE`이