fix(realtime): redact browser event details

This commit is contained in:
2026-08-13 00:19:24 +00:00
parent de804a6c2a
commit 08ea4d96bb
8 changed files with 570 additions and 327 deletions
+85
View File
@@ -0,0 +1,85 @@
import {
createFullRealtimeReadModelInvalidation,
hasRealtimeReadModelInvalidation,
mergeRealtimeReadModelInvalidations,
resolveRealtimeReadModelInvalidation,
type PublicRealtimeEvent,
type RealtimeEvent,
type RealtimeReadModelChanges,
type RealtimeViewerIdentity,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts/logic';
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>();
return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const isMailboxRelevant = (mailbox: number, identity: RealtimeViewerIdentity): boolean =>
mailbox === MESSAGE_MAILBOX_PUBLIC ||
(identity.generalId !== null && mailbox === identity.generalId) ||
(identity.nationId !== null && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + identity.nationId);
const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null => {
if (event.type === 'readModelChanged') return event.changes;
if (event.type === 'turnCompleted') return event.changes ?? null;
return null;
};
export const shouldReloadRealtimeViewerIdentity = (
event: RealtimeEvent,
identity: RealtimeViewerIdentity
): boolean => {
if (identity.generalId === null) return false;
const changes = eventChanges(event);
if (!changes) return false;
const generalId = identity.generalId;
return [
changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds,
changes.frontStatusGeneralIds ?? [],
changes.frontStatusActorIds ?? [],
changes.lobbyGeneralIds ?? changes.generalIds,
changes.reservedGeneralIds,
changes.recordGeneralIds,
].some((ids) => ids.includes(generalId));
};
/**
* Converts an internal Redis event to the minimal browser contract. Empty
* clock-only turn events are suppressed; the remaining payload never includes
* entity IDs, wall-clock timestamps, logical turn times, or revisions.
*/
export const toPublicRealtimeEvent = (
event: RealtimeEvent,
identities: readonly RealtimeViewerIdentity[]
): PublicRealtimeEvent | null => {
const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated') {
return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity))
? { type: 'messagesInvalidated' }
: null;
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
invalidation: createFullRealtimeReadModelInvalidation(),
};
}
const changes = eventChanges(event);
if (!changes) return null;
const invalidation = viewers
.map((identity) => resolveRealtimeReadModelInvalidation(changes, identity))
.reduce(mergeRealtimeReadModelInvalidations);
if (!hasRealtimeReadModelInvalidation(invalidation)) return null;
return { type: 'readModelInvalidated', invalidation };
};
+38 -9
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { buildGameEventChannel } from '@sammo-ts/common';
import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
@@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
@@ -229,6 +230,17 @@ export const createGameApiServer = async () => {
return;
}
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true },
});
return general
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
: { generalId: null, cityId: null, nationId: null };
};
let viewerIdentity = await loadViewerIdentity();
reply.hijack();
const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
@@ -256,30 +268,47 @@ export const createGameApiServer = async () => {
sendFrame(
formatSseFrame({
event: 'ready',
data: JSON.stringify({ at: new Date().toISOString() }),
data: '{}',
})
);
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => {
sendFrame(
formatSseFrame({
event: event.type,
data: JSON.stringify(event),
id: event.at,
eventQueue = eventQueue
.then(async () => {
if (closed) return;
const identities = [viewerIdentity];
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity();
identities.push(nextIdentity);
viewerIdentity = nextIdentity;
}
const publicEvent = toPublicRealtimeEvent(event, identities);
if (!publicEvent || closed) return;
sendFrame(
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
})
);
.catch(() => {
// A best-effort notification must not affect committed game state.
});
});
const heartbeat = setInterval(() => {
sendFrame(
formatSseFrame({
event: 'ping',
data: JSON.stringify({ at: new Date().toISOString() }),
data: '{}',
})
);
}, 15000);
const close = () => {
closed = true;
clearInterval(heartbeat);
unsubscribe();
};