fix(realtime): redact browser event details
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||
|
||||
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
|
||||
|
||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
||||
|
||||
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
|
||||
type: 'turnCompleted',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes,
|
||||
revision: 42,
|
||||
});
|
||||
|
||||
describe('public realtime event privacy boundary', () => {
|
||||
it('suppresses clock-only and unrelated private general turns', () => {
|
||||
expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull();
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
turnEvent({
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [99],
|
||||
}),
|
||||
[viewer]
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes only viewer-specific boolean invalidations', () => {
|
||||
const publicEvent = toPublicRealtimeEvent(
|
||||
turnEvent({
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [7, 99],
|
||||
reservedGeneralIds: [7],
|
||||
recordGeneralIds: [7],
|
||||
}),
|
||||
[viewer]
|
||||
);
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: false,
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(publicEvent);
|
||||
expect(publicEvent).not.toHaveProperty('at');
|
||||
expect(publicEvent).not.toHaveProperty('lastTurnTime');
|
||||
expect(publicEvent).not.toHaveProperty('revision');
|
||||
for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps global refresh meaning without exposing its source identity or time', () => {
|
||||
const publicEvent = toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'readModelChanged',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
revision: 43,
|
||||
changes: {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
worldChanged: true,
|
||||
globalRecordsChanged: true,
|
||||
worldHistoryChanged: true,
|
||||
},
|
||||
},
|
||||
[viewer]
|
||||
);
|
||||
|
||||
expect(publicEvent).toMatchObject({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: { lobby: true, map: true, commands: true, records: true },
|
||||
});
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u);
|
||||
});
|
||||
|
||||
it('uses a conservative identifier-free fallback for an older daemon', () => {
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'turnCompleted',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
},
|
||||
[viewer]
|
||||
)
|
||||
).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: true,
|
||||
map: true,
|
||||
commands: true,
|
||||
contacts: true,
|
||||
boardAccess: true,
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messageCreated',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId,
|
||||
msgType: 'national',
|
||||
messageId: 123,
|
||||
senderId: 99,
|
||||
};
|
||||
|
||||
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
|
||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||
});
|
||||
|
||||
it('requests an identity refresh only when the viewer general may have changed', () => {
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }),
|
||||
viewer
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }),
|
||||
viewer
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('merges previous and committed identities across an ownership transition', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'readModelChanged',
|
||||
at: '2026-08-12T12:34:56.789Z',
|
||||
revision: 44,
|
||||
changes: {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
generalIds: [7],
|
||||
nationIds: [3],
|
||||
frontStatusNationIds: [3],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
|
||||
).toMatchObject({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: true,
|
||||
commands: true,
|
||||
boardAccess: true,
|
||||
frontStatus: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user