diff --git a/app/game-api/src/auth/realtimeAccessGrant.ts b/app/game-api/src/auth/realtimeAccessGrant.ts new file mode 100644 index 00000000..d8f91ba0 --- /dev/null +++ b/app/game-api/src/auth/realtimeAccessGrant.ts @@ -0,0 +1,143 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +export const REALTIME_ACCESS_GRANT_TTL_MS = 15_000; + +type RealtimeAccessGrantPayload = { + version: 1; + profile: string; + sessionId: string; + userId: string; + expiresAt: number; +}; + +const GRANT_KEY_CONTEXT = 'sammo:realtime-access-grant:v1'; +const MAX_GRANT_LENGTH = 1_024; + +interface RedisClientLike { + set(key: string, value: string, options: { NX: true; PX: number }): Promise; + eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise; +} + +const CONSUME_GRANT_SCRIPT = ` +if redis.call('DEL', KEYS[1]) == 1 then + return 1 +end +return 0 +`; + +const buildKey = (secret: string): Buffer => + createHash('sha256').update(GRANT_KEY_CONTEXT).update('\0').update(secret).digest(); + +const buildUsageKey = (profileName: string, grant: string): string => + `sammo:game:realtime-access-grant:${profileName}:${createHash('sha256').update(grant).digest('base64url')}`; + +const parsePayload = (value: unknown): RealtimeAccessGrantPayload | null => { + if (!value || typeof value !== 'object') return null; + const payload = value as Partial; + if ( + payload.version !== 1 || + typeof payload.profile !== 'string' || + typeof payload.sessionId !== 'string' || + typeof payload.userId !== 'string' || + typeof payload.expiresAt !== 'number' || + !Number.isSafeInteger(payload.expiresAt) + ) { + return null; + } + return payload as RealtimeAccessGrantPayload; +}; + +export const createRealtimeAccessGrant = ( + auth: GameSessionTokenPayload, + profileName: string, + secret: string, + now = new Date() +): string => { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', buildKey(secret), iv); + const payload: RealtimeAccessGrantPayload = { + version: 1, + profile: profileName, + sessionId: auth.sessionId, + userId: auth.user.id, + expiresAt: now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS, + }; + const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]); + return [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString('base64url')).join('.'); +}; + +export const verifyRealtimeAccessGrant = ( + grant: string | null | undefined, + auth: GameSessionTokenPayload | null, + profileName: string, + secret: string, + now = new Date() +): boolean => { + if (!grant || grant.length > MAX_GRANT_LENGTH || !auth) return false; + const parts = grant.split('.'); + if (parts.length !== 3) return false; + try { + const [ivPart, encryptedPart, tagPart] = parts; + const decipher = createDecipheriv('aes-256-gcm', buildKey(secret), Buffer.from(ivPart, 'base64url')); + decipher.setAuthTag(Buffer.from(tagPart, 'base64url')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(encryptedPart, 'base64url')), + decipher.final(), + ]).toString('utf8'); + const payload = parsePayload(JSON.parse(plaintext)); + return Boolean( + payload && + payload.expiresAt > now.getTime() && + payload.profile === profileName && + payload.sessionId === auth.sessionId && + payload.userId === auth.user.id + ); + } catch { + return false; + } +}; + +export const verifyRealtimeAccessGrantHeader = ( + header: string | string[] | undefined, + auth: GameSessionTokenPayload | null, + profileName: string, + secret: string, + now = new Date() +): boolean => verifyRealtimeAccessGrant(Array.isArray(header) ? header[0] : header, auth, profileName, secret, now); + +export const registerRealtimeAccessGrant = async ( + redis: RedisClientLike, + grant: string, + profileName: string +): Promise => + (await redis.set(buildUsageKey(profileName, grant), '1', { + NX: true, + PX: REALTIME_ACCESS_GRANT_TTL_MS, + })) === 'OK'; + +export const consumeRealtimeAccessGrantHeader = async ( + redis: RedisClientLike, + header: string | string[] | undefined, + auth: GameSessionTokenPayload | null, + profileName: string, + secret: string, + now = new Date() +): Promise => { + const grant = Array.isArray(header) ? header[0] : header; + if (!verifyRealtimeAccessGrant(grant, auth, profileName, secret, now) || !grant || !redis.eval) { + return false; + } + try { + return ( + Number( + await redis.eval(CONSUME_GRANT_SCRIPT, { + keys: [buildUsageKey(profileName, grant)], + arguments: [], + }) + ) === 1 + ); + } catch { + return false; + } +}; diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index ff4d5003..99f61b54 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -87,6 +87,8 @@ export type DatabaseClient = InfraDatabaseClient; export interface GameApiContext { requestId?: string; generalAccessTracking?: boolean; + /** Validated server-issued proof for one realtime refresh burst. */ + realtimeAccessGranted?: boolean; /** Request-local identity already resolved by the realtime access gate. */ realtimeAccessGeneralId?: number; /** Set only while an API input-event transaction owns the mutation. */ @@ -115,6 +117,7 @@ export interface GameApiContext { export const createGameApiContext = (options: { requestId?: string; + realtimeAccessGranted?: boolean; db: DatabaseClient; redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; @@ -136,6 +139,7 @@ export const createGameApiContext = (options: { return { requestId: options.requestId, generalAccessTracking: true, + ...(options.realtimeAccessGranted ? { realtimeAccessGranted: true } : {}), db: options.db, redis: options.redis, turnDaemon: options.turnDaemon, diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index 20b086cb..00b06ba2 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -31,10 +31,7 @@ const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null => return null; }; -export const shouldReloadRealtimeViewerIdentity = ( - event: RealtimeEvent, - identity: RealtimeViewerIdentity -): boolean => { +export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identity: RealtimeViewerIdentity): boolean => { if (identity.generalId === null) return false; const changes = eventChanges(event); if (!changes) return false; @@ -57,7 +54,8 @@ export const shouldReloadRealtimeViewerIdentity = ( */ export const toPublicRealtimeEvent = ( event: RealtimeEvent, - identities: readonly RealtimeViewerIdentity[] + identities: readonly RealtimeViewerIdentity[], + createRefreshGrant: () => string ): PublicRealtimeEvent | null => { const viewers = uniqueIdentities( identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] @@ -65,13 +63,14 @@ export const toPublicRealtimeEvent = ( if (event.type === 'messageCreated' || event.type === 'messagesChanged') { const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) - ? { type: 'messagesInvalidated' } + ? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() } : null; } if (event.type === 'tournamentChanged') { return { type: 'readModelInvalidated', + refreshGrant: createRefreshGrant(), invalidation: { context: false, lobby: false, @@ -90,6 +89,7 @@ export const toPublicRealtimeEvent = ( if (event.type === 'turnCompleted' && !event.changes) { return { type: 'readModelInvalidated', + refreshGrant: createRefreshGrant(), invalidation: createFullRealtimeReadModelInvalidation(), }; } @@ -100,5 +100,5 @@ export const toPublicRealtimeEvent = ( .map((identity) => resolveRealtimeReadModelInvalidation(changes, identity)) .reduce(mergeRealtimeReadModelInvalidations); if (!hasRealtimeReadModelInvalidation(invalidation)) return null; - return { type: 'readModelInvalidated', invalidation }; + return { type: 'readModelInvalidated', invalidation, refreshGrant: createRefreshGrant() }; }; diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts index 2df96263..299fc941 100644 --- a/app/game-api/src/router/dashboard/index.ts +++ b/app/game-api/src/router/dashboard/index.ts @@ -10,6 +10,12 @@ import { type DashboardSourceSlice, } from '../../services/dashboardSourceRevision.js'; import { createReadModelDelta } from '../../services/readModelDeltaCache.js'; +import { + DASHBOARD_PROJECTION_ACCESS_WEIGHT, + formatGeneralAccessLimitMessage, + getGeneralAccessState, + recordGeneralAccessWeight, +} from '../../services/generalAccess.js'; import { getBoardAccess } from '../board/index.js'; import { getGeneralContext } from '../general/index.js'; import { getTurnCommandTable } from '../turns/index.js'; @@ -39,6 +45,31 @@ const zContextBundleInput = z.object({ forceSnapshot: z.boolean().optional(), }); +type DashboardSliceRequest = { + included: boolean; + sourceState: DashboardSourceRevisionState | null; + slice: DashboardSourceSlice; + knownContent?: string; + knownSource?: string; + forceSnapshot?: boolean; +}; + +export const requiresDashboardProjection = (request: DashboardSliceRequest): boolean => { + if (!request.included) return false; + const sourceRevision = request.sourceState?.sourceRevisions[request.slice]; + return !( + sourceRevision !== undefined && + request.knownContent !== undefined && + canUseDashboardSourceRevision({ + state: request.sourceState, + slice: request.slice, + knownContent: request.knownContent, + knownSource: request.knownSource, + forceSnapshot: request.forceSnapshot, + }) + ); +}; + const createDashboardSliceDelta = async (options: { included: boolean; sourceState: DashboardSourceRevisionState | null; @@ -54,17 +85,7 @@ const createDashboardSliceDelta = async (options: { } const sourceRevision = options.sourceState?.sourceRevisions[options.slice]; - if ( - sourceRevision !== undefined && - options.knownContent !== undefined && - canUseDashboardSourceRevision({ - state: options.sourceState, - slice: options.slice, - knownContent: options.knownContent, - knownSource: options.knownSource, - forceSnapshot: options.forceSnapshot, - }) - ) { + if (!requiresDashboardProjection(options) && options.knownContent !== undefined) { return { kind: 'unchanged', revision: options.knownContent, @@ -102,9 +123,48 @@ export const dashboardRouter = router({ )?.id ?? null; } - const sourceState = generalId - ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) - : null; + let sourceState = generalId ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null; + const buildSliceRequests = (): DashboardSliceRequest[] => [ + { + included: input.include.context, + sourceState, + slice: 'context', + knownContent: input.known?.context, + knownSource: input.knownSource?.context, + forceSnapshot: input.forceSnapshot, + }, + { + included: input.include.commandTable && generalId !== null, + sourceState, + slice: 'commandTable', + knownContent: input.known?.commandTable, + knownSource: input.knownSource?.commandTable, + forceSnapshot: input.forceSnapshot, + }, + { + included: input.include.boardAccess && generalId !== null, + sourceState, + slice: 'boardAccess', + knownContent: input.known?.boardAccess, + knownSource: input.knownSource?.boardAccess, + forceSnapshot: input.forceSnapshot, + }, + ]; + const sliceRequests = buildSliceRequests(); + const rebuildsPostgresProjection = sliceRequests.some(requiresDashboardProjection); + if (rebuildsPostgresProjection && ctx.generalAccessTracking === true && ctx.realtimeAccessGranted !== true) { + const recorded = await recordGeneralAccessWeight(ctx, DASHBOARD_PROJECTION_ACCESS_WEIGHT); + const accessState = await getGeneralAccessState(ctx); + if (accessState?.level === 2) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: formatGeneralAccessLimitMessage(accessState), + }); + } + if (recorded && generalId !== null) { + sourceState = await readDashboardSourceRevisionState(ctx.db, generalId, authUser); + } + } const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([ createDashboardSliceDelta({ diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 8f37ac7e..5d062e4c 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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, type RealtimeViewerIdentity } from '@sammo-ts/common'; +import { buildGameEventChannel, REALTIME_ACCESS_GRANT_HEADER, type RealtimeViewerIdentity } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, @@ -18,6 +18,11 @@ import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './ import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js'; import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; +import { + consumeRealtimeAccessGrantHeader, + createRealtimeAccessGrant, + registerRealtimeAccessGrant, +} from './auth/realtimeAccessGrant.js'; import { appRouter } from './router.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; @@ -222,6 +227,13 @@ export const createGameApiServer = async () => { uploadPublicUrl: config.uploadPublicUrl, contentImageUpload, auth, + realtimeAccessGranted: await consumeRealtimeAccessGrantHeader( + redis.client, + req.headers[REALTIME_ACCESS_GRANT_HEADER], + auth, + config.profileName, + config.gameTokenSecret + ), ...(auth && token ? { accessToken: token } : {}), accessTokenStore, flushStore, @@ -299,8 +311,20 @@ export const createGameApiServer = async () => { identities.push(nextIdentity); viewerIdentity = nextIdentity; } - const publicEvent = toPublicRealtimeEvent(event, identities); + let refreshGrant: string | undefined; + const publicEvent = toPublicRealtimeEvent(event, identities, () => { + refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret); + return refreshGrant; + }); if (!publicEvent || closed) return; + if (refreshGrant) { + try { + await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName); + } catch { + // Preserve the invalidation. An unregistered grant safely falls back + // to the normal scored refresh path. + } + } sendFrame( formatSseFrame({ event: publicEvent.type, diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index bba0a5d0..c6d8737c 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -25,6 +25,9 @@ export const accessPageWeights: Record = { 'npc-control': 1, }; +/** One user-visible refresh that has to rebuild PostgreSQL-backed dashboard data. */ +export const DASHBOARD_PROJECTION_ACCESS_WEIGHT = 1; + export const generalAccessEndpointWeights = { 'world.getGeneralDirectory': 2, 'public.getNpcList': 2, @@ -369,9 +372,7 @@ export const upsertGeneralAccess = async ( ` ); - await writeReadModelChangeJournal(transaction, [ - { domain: 'access.general', entityId: input.generalId }, - ]); + await writeReadModelChangeJournal(transaction, [{ domain: 'access.general', entityId: input.generalId }]); }); }; diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index a44468b5..139cd430 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -4,7 +4,7 @@ import { applyReadModelDelta } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameApiContext } from '../src/context.js'; -import { dashboardRouter } from '../src/router/dashboard/index.js'; +import { dashboardRouter, requiresDashboardProjection } from '../src/router/dashboard/index.js'; const auth: GameSessionTokenPayload = { version: 1, @@ -141,6 +141,55 @@ const contextOnly = { }; describe('dashboardRouter.getContextBundleDelta', () => { + it('classifies revision-only checks separately from PostgreSQL projection rebuilds', () => { + const sourceRevision = 'S'.repeat(22); + const sourceState = { + coverageVersion: 1, + identity: { generalId: 7, cityId: 0, nationId: 0 }, + sourceRevisions: { + context: sourceRevision, + commandTable: 'T'.repeat(22), + boardAccess: 'B'.repeat(22), + }, + }; + + expect( + requiresDashboardProjection({ + included: true, + sourceState, + slice: 'context', + knownContent: 'C'.repeat(22), + knownSource: sourceRevision, + }) + ).toBe(false); + expect( + requiresDashboardProjection({ + included: true, + sourceState, + slice: 'context', + knownContent: 'C'.repeat(22), + knownSource: 'X'.repeat(22), + }) + ).toBe(true); + expect( + requiresDashboardProjection({ + included: true, + sourceState, + slice: 'context', + knownContent: 'C'.repeat(22), + knownSource: sourceRevision, + forceSnapshot: true, + }) + ).toBe(true); + expect( + requiresDashboardProjection({ + included: false, + sourceState, + slice: 'context', + }) + ).toBe(false); + }); + it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => { const fixture = buildContext(true); const caller = dashboardRouter.createCaller(fixture.context); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index 1d4e3235..3f7b06b7 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -58,12 +58,25 @@ integration('general access tracking persistence', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let worldStateId: number; + let previousCoverageVersion: number | null; beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); + previousCoverageVersion = + ( + await db.readModelRevisionMeta.findUnique({ + where: { id: 1 }, + select: { coverageVersion: true }, + }) + )?.coverageVersion ?? null; + await db.readModelRevisionMeta.upsert({ + where: { id: 1 }, + create: { id: 1, coverageVersion: 1 }, + update: { coverageVersion: 1 }, + }); await db.generalAccessLog.deleteMany({ where: { generalId: { @@ -126,6 +139,14 @@ integration('general access tracking persistence', () => { await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); await db.general.deleteMany({ where: { id: endpointGeneralId } }); await db.worldState.deleteMany({ where: { id: worldStateId } }); + if (previousCoverageVersion === null) { + await db.readModelRevisionMeta.deleteMany({ where: { id: 1 } }); + } else { + await db.readModelRevisionMeta.update({ + where: { id: 1 }, + data: { coverageVersion: previousCoverageVersion }, + }); + } await closeDb?.(); }); @@ -380,6 +401,10 @@ integration('general access tracking persistence', () => { scenario: 'default', }, profileStatusSource: { get: async () => 'RUNNING' as const }, + redis: { + get: async () => null, + set: async () => 'OK', + }, } as unknown as GameApiContext; const boundaryCaller = endpointBoundaryRouter.createCaller(context); @@ -387,12 +412,52 @@ integration('general access tracking persistence', () => { await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined(); await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); + const initialDashboard = await dashboardCaller.dashboard.getContextBundleDelta({ + include: { context: true, commandTable: false, boardAccess: false }, + forceSnapshot: true, + }); + expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } }); + const initialContext = initialDashboard.context; + if (!initialContext?.sourceRevision) { + throw new Error('dashboard snapshot did not include its post-access source revision'); + } + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 }); + + await expect( + dashboardCaller.dashboard.getContextBundleDelta({ + include: { context: true, commandTable: false, boardAccess: false }, + known: { context: initialContext.revision }, + knownSource: { context: initialContext.sourceRevision }, + }) + ).resolves.toMatchObject({ context: { kind: 'unchanged' } }); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) + ).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 }); + + await db.generalAccessLog.delete({ where: { generalId: endpointGeneralId } }); + const realtimeDashboardCaller = appRouter.createCaller({ ...context, realtimeAccessGranted: true }); + await expect( + realtimeDashboardCaller.dashboard.getContextBundleDelta({ + include: { context: true, commandTable: false, boardAccess: false }, + forceSnapshot: true, + }) + ).resolves.toMatchObject({ context: { kind: 'snapshot' } }); + await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); + await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({ code: 'BAD_REQUEST', }); await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); - await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true }); + const grantedBoundaryCaller = endpointBoundaryRouter.createCaller({ + ...context, + realtimeAccessGranted: true, + }); + await expect(grantedBoundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ + ok: true, + }); await expect( db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) ).resolves.toMatchObject({ diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index b409761d..3ebfd96b 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -3,9 +3,15 @@ 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'; +import { + shouldReloadRealtimeViewerIdentity, + toPublicRealtimeEvent as convertPublicRealtimeEvent, +} from '../src/realtime/publicEvent.js'; const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; +const refreshGrant = 'opaque-grant'; +const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters[1]) => + convertPublicRealtimeEvent(event, identities, () => refreshGrant); const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({ type: 'turnCompleted', @@ -42,6 +48,7 @@ describe('public realtime event privacy boundary', () => { expect(publicEvent).toEqual({ type: 'readModelInvalidated', + refreshGrant, invalidation: { context: true, lobby: false, @@ -99,6 +106,7 @@ describe('public realtime event privacy boundary', () => { ) ).toEqual({ type: 'readModelInvalidated', + refreshGrant, invalidation: { context: true, lobby: true, @@ -119,6 +127,7 @@ describe('public realtime event privacy boundary', () => { expect(publicEvent).toEqual({ type: 'readModelInvalidated', + refreshGrant, invalidation: { context: false, lobby: false, @@ -146,7 +155,7 @@ describe('public realtime event privacy boundary', () => { senderId: 99, }; - expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' }); + expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated', refreshGrant }); expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); }); @@ -157,13 +166,10 @@ describe('public realtime event privacy boundary', () => { }; const publicEvent = toPublicRealtimeEvent(event, [viewer]); - expect(publicEvent).toEqual({ type: 'messagesInvalidated' }); + expect(publicEvent).toEqual({ type: 'messagesInvalidated', refreshGrant }); expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u); expect( - toPublicRealtimeEvent( - { type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, - [viewer] - ) + toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, [viewer]) ).toBeNull(); }); @@ -195,9 +201,7 @@ describe('public realtime event privacy boundary', () => { }, }; - expect( - toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }]) - ).toMatchObject({ + expect(toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])).toMatchObject({ type: 'readModelInvalidated', invalidation: { context: true, diff --git a/app/game-api/test/realtimeAccessGrant.test.ts b/app/game-api/test/realtimeAccessGrant.test.ts new file mode 100644 index 00000000..6dcc5f6a --- /dev/null +++ b/app/game-api/test/realtimeAccessGrant.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +import { + consumeRealtimeAccessGrantHeader, + createRealtimeAccessGrant, + REALTIME_ACCESS_GRANT_TTL_MS, + registerRealtimeAccessGrant, + verifyRealtimeAccessGrant, + verifyRealtimeAccessGrantHeader, +} from '../src/auth/realtimeAccessGrant.js'; + +const secret = 'realtime-access-grant-test-secret-with-enough-entropy'; +const now = new Date('2026-08-17T10:00:00.000Z'); +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'hwe:default', + issuedAt: '2026-08-17T09:00:00.000Z', + expiresAt: '2026-08-17T11:00:00.000Z', + sessionId: 'session-private-value', + user: { + id: 'user-private-value', + username: 'grant-user', + displayName: '갱신 사용자', + roles: ['user'], + }, + sanctions: {}, +}; + +describe('realtime access grant', () => { + it('binds an opaque short-lived grant to the authenticated session and profile', () => { + const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now); + + expect(grant).not.toContain(auth.user.id); + expect(grant).not.toContain(auth.sessionId); + expect(grant).not.toContain('hwe:default'); + expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, now)).toBe(true); + expect(verifyRealtimeAccessGrantHeader([grant], auth, 'hwe:default', secret, now)).toBe(true); + expect( + verifyRealtimeAccessGrant(grant, { ...auth, sessionId: 'another-session' }, 'hwe:default', secret, now) + ).toBe(false); + expect(verifyRealtimeAccessGrant(grant, auth, 'che:default', secret, now)).toBe(false); + }); + + it('rejects expired, tampered, unauthenticated, and malformed grants', () => { + const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now); + const atExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS); + const afterExpiry = new Date(now.getTime() + REALTIME_ACCESS_GRANT_TTL_MS + 1); + const grantParts = grant.split('.'); + const encryptedPart = grantParts[1] ?? ''; + grantParts[1] = `${encryptedPart.startsWith('A') ? 'B' : 'A'}${encryptedPart.slice(1)}`; + const tampered = grantParts.join('.'); + + expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, atExpiry)).toBe(false); + expect(verifyRealtimeAccessGrant(grant, auth, 'hwe:default', secret, afterExpiry)).toBe(false); + expect(verifyRealtimeAccessGrant(tampered, auth, 'hwe:default', secret, now)).toBe(false); + expect(verifyRealtimeAccessGrant(grant, null, 'hwe:default', secret, now)).toBe(false); + expect(verifyRealtimeAccessGrant('not-a-grant', auth, 'hwe:default', secret, now)).toBe(false); + }); + + it('registers a grant in Redis and consumes it exactly once', async () => { + const values = new Set(); + const redis = { + set: async (key: string) => { + if (values.has(key)) return null; + values.add(key); + return 'OK'; + }, + eval: async (_script: string, options: { keys: string[] }) => + values.delete(options.keys[0] ?? '') ? 1 : 0, + }; + const grant = createRealtimeAccessGrant(auth, 'hwe:default', secret, now); + + await expect(registerRealtimeAccessGrant(redis, grant, 'hwe:default')).resolves.toBe(true); + await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe( + true + ); + await expect(consumeRealtimeAccessGrantHeader(redis, grant, auth, 'hwe:default', secret, now)).resolves.toBe( + false + ); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index bf51dec7..1e00c484 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; +const realtimeAccessGrantHeader = 'x-sammo-realtime-access-grant'; +const fixtureRealtimeAccessGrant = 'fixture-realtime-grant'; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); @@ -63,6 +65,7 @@ type NavigationFixture = { boardAccessKind: string | null; }>; dashboardRequests?: DashboardBundleInput[]; + dashboardGrantHeaders?: Array; }; type JsonPatchOperation = { @@ -384,7 +387,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } if (operation === 'dashboard.getContextBundleDelta') { state.generalMeCalls += 1; - if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) { + const refreshGrant = route.request().headers()[realtimeAccessGrantHeader] ?? null; + (state.dashboardGrantHeaders ??= []).push(refreshGrant); + if ( + state.accessLimitAfterCalls !== undefined && + state.generalMeCalls > state.accessLimitAfterCalls && + refreshGrant !== fixtureRealtimeAccessGrant + ) { return errorResponse( operation, '접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' + @@ -588,7 +597,15 @@ const installRealtimeHarness = async (page: Page) => { configurable: true, value: (type: string, payload: unknown) => { TestEventSource.latest?.dispatchEvent( - new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) }) + new MessageEvent(type, { + data: JSON.stringify({ + type, + ...(type === 'readModelInvalidated' || type === 'messagesInvalidated' + ? { refreshGrant: 'fixture-realtime-grant' } + : {}), + ...((payload as object) ?? {}), + }), + }) ); }, }); @@ -2604,6 +2621,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl .toBe(true); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); + expect(state.dashboardGrantHeaders).toContain(null); const operationsBeforeTournament = state.operations.length; state.stage = 1; @@ -2613,6 +2631,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl .toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u); + expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant); await page.evaluate(() => { const general = document.querySelector('[data-main-target="general"]'); @@ -2925,7 +2944,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu .toBe(true); const operationsBeforeLimit = state.operations.length; - await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true })); + await page.evaluate( + (invalidation) => { + (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( + 'readModelInvalidated', + { invalidation, refreshGrant: 'expired-grant' } + ); + }, + readModelInvalidation({ records: true, map: true }) + ); await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.'); await expect @@ -2948,6 +2975,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) ) .toBe(true); + expect(state.dashboardGrantHeaders?.at(-1)).toBeNull(); }); test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => { diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 30ca2e36..fdce2454 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -20,6 +20,7 @@ import { } from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; +import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant'; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; @@ -519,27 +520,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const fetchContextBundlePatch = async ( include: DashboardContextBundleInclude, - forceSnapshot = false + forceSnapshot = false, + refreshGrant?: string ): Promise => { + const queryOptions = createRealtimeRequestOptions(refreshGrant); const request = (force: boolean) => - trpc.dashboard.getContextBundleDelta.query({ - include, - known: force - ? undefined - : { - ...(contextRevision ? { context: contextRevision } : {}), - ...(commandTableRevision ? { commandTable: commandTableRevision } : {}), - ...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}), - }, - knownSource: force - ? undefined - : { - ...(contextSourceRevision ? { context: contextSourceRevision } : {}), - ...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}), - ...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}), - }, - forceSnapshot: force || undefined, - }); + trpc.dashboard.getContextBundleDelta.query( + { + include, + known: force + ? undefined + : { + ...(contextRevision ? { context: contextRevision } : {}), + ...(commandTableRevision ? { commandTable: commandTableRevision } : {}), + ...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}), + }, + knownSource: force + ? undefined + : { + ...(contextSourceRevision ? { context: contextSourceRevision } : {}), + ...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}), + ...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}), + }, + forceSnapshot: force || undefined, + }, + queryOptions + ); return resolveWithReadModelSnapshotFallback({ request, @@ -674,7 +680,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } ); - const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => { + const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation, refreshGrant: string) => { const id = generalId.value; if (!id) { return; @@ -688,37 +694,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (plan.records) recordsError.value = null; if (plan.frontStatus) frontStatusError.value = null; try { + const queryOptions = createRealtimeRequestOptions(refreshGrant); // Every automatic refresh crosses this access-limit gate before // any selected follow-up query starts. An all-false bundle is an // access-only check and does not project general context. - const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan)); + const contextPatch = await fetchContextBundlePatch( + resolveDashboardContextBundleInclude(plan), + false, + refreshGrant + ); accessLimited.value = false; - const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined); + const lobbyPromise = plan.lobby + ? trpc.lobby.info.query(undefined, queryOptions) + : Promise.resolve(undefined); const mapPromise = plan.map - ? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }) + ? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }, queryOptions) : Promise.resolve(undefined); const contactsPromise = plan.contacts - ? trpc.messages.getContacts.query({ generalId: id }) + ? trpc.messages.getContacts.query({ generalId: id }, queryOptions) : Promise.resolve(undefined); const reservedPromise = plan.reservedTurns - ? trpc.turns.reserved.getGeneral.query({ generalId: id }) + ? trpc.turns.reserved.getGeneral.query({ generalId: id }, queryOptions) : Promise.resolve(undefined); const recordsPromise = plan.records ? trpc.general.getRecentRecords - .query({ lastGeneralRecordId, lastWorldHistoryId }) + .query({ lastGeneralRecordId, lastWorldHistoryId }, queryOptions) .catch((err: unknown) => { recordsError.value = resolveErrorMessage(err); return null; }) : Promise.resolve(undefined); const frontPromise = plan.frontStatus - ? trpc.general.getFrontStatus.query().catch((err: unknown) => { + ? trpc.general.getFrontStatus.query(undefined, queryOptions).catch((err: unknown) => { frontStatusError.value = resolveErrorMessage(err); return null; }) : Promise.resolve(undefined); const tournamentPromise: Promise = plan.tournament - ? trpc.tournament.getState.query().catch(() => undefined) + ? trpc.tournament.getState.query(undefined, queryOptions).catch(() => undefined) : Promise.resolve(undefined); const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([ @@ -761,13 +774,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels); - const refreshMessages = async () => { + const refreshMessages = async (refreshGrant?: string) => { const id = generalId.value; if (!id) { return; } try { - const nextMessages = await trpc.messages.getRecent.query({ generalId: id }); + const nextMessages = await trpc.messages.getRecent.query( + { generalId: id }, + createRealtimeRequestOptions(refreshGrant) + ); const patch = { messages: nextMessages } satisfies DashboardReadModelPatch; applyDashboardPatch(patch); publishDashboardPatch(patch); @@ -1145,7 +1161,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (!payload || payload.type !== 'readModelInvalidated') { return; } - readModelRefreshQueue.request(payload.invalidation); + readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant); }); source.addEventListener('messagesInvalidated', (event) => { if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; @@ -1153,7 +1169,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (!payload || payload.type !== 'messagesInvalidated') { return; } - void refreshMessages(); + void refreshMessages(payload.refreshGrant); }); // Rolling deployment fallback: an older API may still expose internal diff --git a/app/game-frontend/src/utils/dashboardReadModel.ts b/app/game-frontend/src/utils/dashboardReadModel.ts index 381c5fa2..f532fefb 100644 --- a/app/game-frontend/src/utils/dashboardReadModel.ts +++ b/app/game-frontend/src/utils/dashboardReadModel.ts @@ -20,9 +20,7 @@ export const resolveDashboardRefreshPlan = ( identity: DashboardReadModelIdentity ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); -export const resolveDashboardContextBundleInclude = ( - plan: DashboardRefreshPlan -): DashboardContextBundleInclude => ({ +export const resolveDashboardContextBundleInclude = (plan: DashboardRefreshPlan): DashboardContextBundleInclude => ({ context: plan.context, commandTable: plan.commands, boardAccess: plan.boardAccess, @@ -31,12 +29,12 @@ export const resolveDashboardContextBundleInclude = ( type TimerHandle = ReturnType; export interface MergedReadModelRefreshQueue { - request(invalidation: RealtimeReadModelInvalidation): void; + request(invalidation: RealtimeReadModelInvalidation, refreshGrant: string): void; cancelPending(): void; } export const createMergedReadModelRefreshQueue = ( - refresh: (invalidation: RealtimeReadModelInvalidation) => Promise, + refresh: (invalidation: RealtimeReadModelInvalidation, refreshGrant: string) => Promise, options: { minIntervalMs?: number; now?: () => number; @@ -49,6 +47,7 @@ export const createMergedReadModelRefreshQueue = ( const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); let pending = createEmptyRealtimeReadModelInvalidation(); + let pendingRefreshGrant = ''; let hasPending = false; let running = false; let timer: TimerHandle | null = null; @@ -65,11 +64,13 @@ export const createMergedReadModelRefreshQueue = ( return; } const next = pending; + const nextRefreshGrant = pendingRefreshGrant; pending = createEmptyRealtimeReadModelInvalidation(); + pendingRefreshGrant = ''; hasPending = false; running = true; lastStartedAt = now(); - void refresh(next).finally(() => { + void refresh(next, nextRefreshGrant).finally(() => { running = false; schedule(); }); @@ -77,14 +78,16 @@ export const createMergedReadModelRefreshQueue = ( }; return { - request: (invalidation) => { + request: (invalidation, refreshGrant) => { pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation; + pendingRefreshGrant = refreshGrant; hasPending = true; schedule(); }, cancelPending: () => { hasPending = false; pending = createEmptyRealtimeReadModelInvalidation(); + pendingRefreshGrant = ''; if (timer !== null) { clearTimer(timer); timer = null; diff --git a/app/game-frontend/src/utils/realtimeAccessGrant.ts b/app/game-frontend/src/utils/realtimeAccessGrant.ts new file mode 100644 index 00000000..b7566c77 --- /dev/null +++ b/app/game-frontend/src/utils/realtimeAccessGrant.ts @@ -0,0 +1,21 @@ +export const REALTIME_ACCESS_GRANT_CONTEXT_KEY = 'realtimeAccessGrant'; + +export const createRealtimeRequestOptions = (refreshGrant: string | null | undefined) => + refreshGrant + ? { + context: { + [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: refreshGrant, + }, + } + : undefined; + +export const resolveBatchRealtimeAccessGrant = ( + operations: ReadonlyArray<{ context: Record }> +): string | undefined => { + if (operations.length === 0) return undefined; + const first = operations[0]?.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY]; + if (typeof first !== 'string' || first.length === 0) return undefined; + return operations.every((operation) => operation.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY] === first) + ? first + : undefined; +}; diff --git a/app/game-frontend/src/utils/trpc.ts b/app/game-frontend/src/utils/trpc.ts index 7486b0e8..1e47124d 100644 --- a/app/game-frontend/src/utils/trpc.ts +++ b/app/game-frontend/src/utils/trpc.ts @@ -1,5 +1,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/game-api'; +import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common'; +import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant'; const getGameToken = (): string | null => { if (typeof window === 'undefined') { @@ -13,9 +15,13 @@ export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', - headers() { + headers({ opList }) { const token = getGameToken(); - return token ? { authorization: `Bearer ${token}` } : {}; + const refreshGrant = resolveBatchRealtimeAccessGrant(opList); + return { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(refreshGrant ? { [REALTIME_ACCESS_GRANT_HEADER]: refreshGrant } : {}), + }; }, }), ], diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index 651c15f3..4334564b 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -207,10 +207,10 @@ void test('merges browser-safe boolean invalidations and starts at most once per let nowMs = 0; let nextTimerId = 1; const timers = new Map void; at: number }>(); - const observed: Array<{ context: boolean; records: boolean }> = []; + const observed: Array<{ context: boolean; records: boolean; refreshGrant: string }> = []; const queue = createMergedReadModelRefreshQueue( - async (invalidation) => { - observed.push({ context: invalidation.context, records: invalidation.records }); + async (invalidation, refreshGrant) => { + observed.push({ context: invalidation.context, records: invalidation.records, refreshGrant }); }, { minIntervalMs: 1_000, @@ -232,13 +232,13 @@ void test('merges browser-safe boolean invalidations and starts at most once per } }; - queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-a'); runDueTimers(); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(observed, [{ context: true, records: false }]); + assert.deepEqual(observed, [{ context: true, records: false, refreshGrant: 'grant-a' }]); - queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }); - queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-b'); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }, 'grant-c'); nowMs = 999; runDueTimers(); assert.equal(observed.length, 1); @@ -246,7 +246,7 @@ void test('merges browser-safe boolean invalidations and starts at most once per runDueTimers(); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(observed, [ - { context: true, records: false }, - { context: true, records: true }, + { context: true, records: false, refreshGrant: 'grant-a' }, + { context: true, records: true, refreshGrant: 'grant-c' }, ]); }); diff --git a/app/game-frontend/test/realtimeAccessGrant.test.ts b/app/game-frontend/test/realtimeAccessGrant.test.ts new file mode 100644 index 00000000..baf86ca5 --- /dev/null +++ b/app/game-frontend/test/realtimeAccessGrant.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRealtimeRequestOptions, + REALTIME_ACCESS_GRANT_CONTEXT_KEY, + resolveBatchRealtimeAccessGrant, +} from '../src/utils/realtimeAccessGrant.ts'; + +void test('adds a realtime grant only to server-signaled request options', () => { + assert.deepEqual(createRealtimeRequestOptions('grant-a'), { + context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' }, + }); + assert.equal(createRealtimeRequestOptions(undefined), undefined); +}); + +void test('sets a batch grant only when every operation carries the same proof', () => { + assert.equal( + resolveBatchRealtimeAccessGrant([ + { context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } }, + { context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } }, + ]), + 'grant-a' + ); + assert.equal( + resolveBatchRealtimeAccessGrant([ + { context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } }, + { context: {} }, + ]), + undefined + ); + assert.equal( + resolveBatchRealtimeAccessGrant([ + { context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } }, + { context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-b' } }, + ]), + undefined + ); +}); diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index dd420019..e7fe2fe8 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -226,18 +226,20 @@ export interface TournamentChangedEvent { export interface ReadModelInvalidatedEvent { type: 'readModelInvalidated'; invalidation: RealtimeReadModelInvalidation; + /** Opaque, short-lived proof that the server initiated this refresh. */ + refreshGrant: string; } export interface MessagesInvalidatedEvent { type: 'messagesInvalidated'; + /** Opaque, short-lived proof that the server initiated this refresh. */ + refreshGrant: string; } +export const REALTIME_ACCESS_GRANT_HEADER = 'x-sammo-realtime-access-grant'; + /** Events safe to expose to an authenticated browser over SSE. */ export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; export type RealtimeEvent = - | TurnCompletedEvent - | ReadModelChangedEvent - | MessageCreatedEvent - | MessagesChangedEvent - | TournamentChangedEvent; + TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent | TournamentChangedEvent;