게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
"types": "./dist/auth/gameToken.d.ts",
|
||||
"default": "./dist/auth/gameToken.js"
|
||||
},
|
||||
"./auth/sessionRevocation": {
|
||||
"types": "./dist/auth/sessionRevocation.d.ts",
|
||||
"default": "./dist/auth/sessionRevocation.js"
|
||||
},
|
||||
"./auth/sanctions": {
|
||||
"types": "./dist/auth/sanctions.d.ts",
|
||||
"default": "./dist/auth/sanctions.js"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Gateway/game API는 같은 Redis namespace의 회수 계약을 공유한다.
|
||||
// 최초 전환 cutoff와 사용자 watermark는 token TTL 설정 변화에도 유효해야 하므로
|
||||
// TTL로 지우지 않는다. 사용자당 한 값이며 삭제는 모든 기존 token 만료 확인 뒤 운영 작업이다.
|
||||
export const buildSessionRevocationBaselineKey = (channel: string): string => `${channel}:revocation:v1:baseline`;
|
||||
export const buildUserSessionRevocationKey = (channel: string, userId: string): string =>
|
||||
`${channel}:revocation:v1:user:${userId}`;
|
||||
|
||||
export const parseSessionRevocationWatermark = (raw: string | null): Date | null => {
|
||||
if (raw === null) return null;
|
||||
if (!/^\d{1,16}$/.test(raw)) throw new Error('Invalid session revocation state.');
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value > 8_640_000_000_000_000)
|
||||
throw new Error('Invalid session revocation state.');
|
||||
return new Date(value);
|
||||
};
|
||||
|
||||
/** A caller timeout does not prove a command already sent to Redis has settled. */
|
||||
export class SessionRevocationCommandBudget {
|
||||
private pending = 0;
|
||||
constructor(private readonly maxPending = 64) {}
|
||||
|
||||
async run<T>(start: () => Promise<T>): Promise<T> {
|
||||
if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.');
|
||||
this.pending += 1;
|
||||
let command: Promise<T>;
|
||||
try {
|
||||
command = start();
|
||||
} catch {
|
||||
this.pending -= 1;
|
||||
throw new Error('Session revocation storage is unavailable.');
|
||||
}
|
||||
void command.then(
|
||||
() => {
|
||||
this.pending -= 1;
|
||||
},
|
||||
() => {
|
||||
this.pending -= 1;
|
||||
}
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
command,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Session revocation storage is unavailable.')), 2000);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new Error('Session revocation storage is unavailable.');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SessionRevocationCommandBudget, parseSessionRevocationWatermark } from '../src/auth/sessionRevocation.js';
|
||||
|
||||
describe('session revocation command ownership', () => {
|
||||
it('holds capacity after caller timeout until the actual command settles, then recovers', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new SessionRevocationCommandBudget(1);
|
||||
let finish!: () => void;
|
||||
const actual = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
try {
|
||||
const timed = expect(budget.run(() => actual)).rejects.toThrow('unavailable');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await timed;
|
||||
const extra = vi.fn(async () => 'extra');
|
||||
await expect(budget.run(extra)).rejects.toThrow('unavailable');
|
||||
expect(extra).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await actual;
|
||||
await expect(budget.run(extra)).resolves.toBe('extra');
|
||||
} finally {
|
||||
finish();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it('releases capacity after synchronous startup or asynchronous backend failure', async () => {
|
||||
const budget = new SessionRevocationCommandBudget(1);
|
||||
await expect(
|
||||
budget.run(() => {
|
||||
throw new Error('synthetic');
|
||||
})
|
||||
).rejects.toThrow('unavailable');
|
||||
await expect(
|
||||
budget.run(async () => {
|
||||
throw new Error('synthetic');
|
||||
})
|
||||
).rejects.toThrow('unavailable');
|
||||
await expect(budget.run(async () => 'ok')).resolves.toBe('ok');
|
||||
});
|
||||
it('validates numeric persisted timestamps while distinguishing absent state', () => {
|
||||
expect(parseSessionRevocationWatermark(null)).toBeNull();
|
||||
expect(parseSessionRevocationWatermark('0')?.getTime()).toBe(0);
|
||||
expect(() => parseSessionRevocationWatermark('NaN')).toThrow();
|
||||
expect(() => parseSessionRevocationWatermark('8640000000000001')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
index: 'src/index.ts',
|
||||
'auth/gameToken': 'src/auth/gameToken.ts',
|
||||
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
|
||||
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',
|
||||
'auth/sanctions': 'src/auth/sanctions.ts',
|
||||
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
|
||||
'http/trpcTransport': 'src/http/trpcTransport.ts',
|
||||
|
||||
Reference in New Issue
Block a user