게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
55 lines
2.2 KiB
TypeScript
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 });
|
|
});
|
|
};
|