feat: Ref 호환 접속 제한과 벌점 초기화 구현
실행 중인 프로필에서만 접속 벌점을 누적하고 제한 임계값과 대상 경로를 Ref 순서에 맞춘다. 자기 턴 명령 성공 시 순간 점수를 같은 flush에서 초기화하며 월간 누적 감쇠는 유지한다. 제한 중 메인 자동 갱신과 실시간 구독을 중지하고 수동 갱신 성공 시 복구한다.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
import type { GatewayProfileRepository } from '../orchestrator/profileRepository.js';
|
||||
|
||||
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
export const registerProfileStatusInternalRoute = (
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
profiles: GatewayProfileRepository;
|
||||
secret: string;
|
||||
}
|
||||
): void => {
|
||||
app.get<{ Params: { profileName: string } }>('/internal/profile-status/:profileName', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const profileName = request.params.profileName;
|
||||
const profile = await options.profiles.getProfile(profileName);
|
||||
if (!profile) {
|
||||
await reply.status(404).send({ ok: false, error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await reply.send({ profileName: profile.profileName, status: profile.status });
|
||||
});
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRep
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
import { registerProfileStatusInternalRoute } from './lobby/profileStatusInternalRoute.js';
|
||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||
@@ -98,6 +99,10 @@ export const createGatewayApiServer = async () => {
|
||||
users,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
registerProfileStatusInternalRoute(app, {
|
||||
profiles,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||
import { registerProfileStatusInternalRoute } from '../src/lobby/profileStatusInternalRoute.js';
|
||||
|
||||
const secret = 'gateway-profile-status-test-secret';
|
||||
const token = createHmac('sha256', secret).update('sammo:profile-status-source:v1').digest('hex');
|
||||
|
||||
describe('profile status internal route', () => {
|
||||
it('requires a purpose-derived token and returns only the durable status', async () => {
|
||||
const app = fastify();
|
||||
const profiles = {
|
||||
getProfile: vi.fn(async (profileName: string) => ({ profileName, status: 'PAUSED' })),
|
||||
} as unknown as GatewayProfileRepository;
|
||||
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/che%3Adefault',
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/che%3Adefault',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({ profileName: 'che:default', status: 'PAUSED' });
|
||||
expect(Object.keys(response.json()).sort()).toEqual(['profileName', 'status']);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown profile', async () => {
|
||||
const app = fastify();
|
||||
const profiles = { getProfile: vi.fn(async () => null) } as unknown as GatewayProfileRepository;
|
||||
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/profile-status/missing',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user