Files
core2026/app/gateway-api/src/webPush/internalRoute.ts
T
Hide_D 7bc3213b03 feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
2026-08-23 13:16:07 +00:00

55 lines
2.2 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';
import { WEB_PUSH_EVENT_TYPES, type WebPushEventEnvelopeV1 } from '@sammo-ts/common';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { WebPushCoordinator } from './coordinator.js';
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
const zEnvelope = z
.object({
version: z.literal(1),
eventId: z.string().min(1).max(500),
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
profileName: z.string().min(1).max(128),
userIds: z.array(z.string().uuid()).max(5_000),
year: z.number().int().min(0).max(9999).optional(),
month: z.number().int().min(1).max(12).optional(),
occurredAt: z.iso.datetime(),
})
.strict();
export const deriveWebPushIngestToken = (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 registerWebPushInternalRoute = (
app: FastifyInstance,
options: { secret: string; webPush: WebPushCoordinator }
): void => {
app.post('/internal/web-push-events', async (request, reply) => {
void reply.header('Cache-Control', 'no-store');
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveWebPushIngestToken(options.secret))) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const parsed = zEnvelope.safeParse(request.body);
if (!parsed.success) {
await reply.status(400).send({ ok: false, error: 'invalid_event' });
return;
}
const result = await options.webPush.ingest(parsed.data as WebPushEventEnvelopeV1);
await reply.send({ ok: true, queued: result.queued });
});
};