feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다
게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
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 });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user