fix: 갱신 비용에 따라 접속 점수를 기록

revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
This commit is contained in:
2026-08-17 11:10:38 +00:00
parent a80d58f761
commit 19629fe3cc
18 changed files with 643 additions and 96 deletions
@@ -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<string | null>;
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
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<RealtimeAccessGrantPayload>;
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<boolean> =>
(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<boolean> => {
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;
}
};
+4
View File
@@ -87,6 +87,8 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext { export interface GameApiContext {
requestId?: string; requestId?: string;
generalAccessTracking?: boolean; generalAccessTracking?: boolean;
/** Validated server-issued proof for one realtime refresh burst. */
realtimeAccessGranted?: boolean;
/** Request-local identity already resolved by the realtime access gate. */ /** Request-local identity already resolved by the realtime access gate. */
realtimeAccessGeneralId?: number; realtimeAccessGeneralId?: number;
/** Set only while an API input-event transaction owns the mutation. */ /** Set only while an API input-event transaction owns the mutation. */
@@ -115,6 +117,7 @@ export interface GameApiContext {
export const createGameApiContext = (options: { export const createGameApiContext = (options: {
requestId?: string; requestId?: string;
realtimeAccessGranted?: boolean;
db: DatabaseClient; db: DatabaseClient;
redis: RedisConnector['client']; redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport; turnDaemon: TurnDaemonTransport;
@@ -136,6 +139,7 @@ export const createGameApiContext = (options: {
return { return {
requestId: options.requestId, requestId: options.requestId,
generalAccessTracking: true, generalAccessTracking: true,
...(options.realtimeAccessGranted ? { realtimeAccessGranted: true } : {}),
db: options.db, db: options.db,
redis: options.redis, redis: options.redis,
turnDaemon: options.turnDaemon, turnDaemon: options.turnDaemon,
+7 -7
View File
@@ -31,10 +31,7 @@ const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null =>
return null; return null;
}; };
export const shouldReloadRealtimeViewerIdentity = ( export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identity: RealtimeViewerIdentity): boolean => {
event: RealtimeEvent,
identity: RealtimeViewerIdentity
): boolean => {
if (identity.generalId === null) return false; if (identity.generalId === null) return false;
const changes = eventChanges(event); const changes = eventChanges(event);
if (!changes) return false; if (!changes) return false;
@@ -57,7 +54,8 @@ export const shouldReloadRealtimeViewerIdentity = (
*/ */
export const toPublicRealtimeEvent = ( export const toPublicRealtimeEvent = (
event: RealtimeEvent, event: RealtimeEvent,
identities: readonly RealtimeViewerIdentity[] identities: readonly RealtimeViewerIdentity[],
createRefreshGrant: () => string
): PublicRealtimeEvent | null => { ): PublicRealtimeEvent | null => {
const viewers = uniqueIdentities( const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
@@ -65,13 +63,14 @@ export const toPublicRealtimeEvent = (
if (event.type === 'messageCreated' || event.type === 'messagesChanged') { if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
? { type: 'messagesInvalidated' } ? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null; : null;
} }
if (event.type === 'tournamentChanged') { if (event.type === 'tournamentChanged') {
return { return {
type: 'readModelInvalidated', type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: { invalidation: {
context: false, context: false,
lobby: false, lobby: false,
@@ -90,6 +89,7 @@ export const toPublicRealtimeEvent = (
if (event.type === 'turnCompleted' && !event.changes) { if (event.type === 'turnCompleted' && !event.changes) {
return { return {
type: 'readModelInvalidated', type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: createFullRealtimeReadModelInvalidation(), invalidation: createFullRealtimeReadModelInvalidation(),
}; };
} }
@@ -100,5 +100,5 @@ export const toPublicRealtimeEvent = (
.map((identity) => resolveRealtimeReadModelInvalidation(changes, identity)) .map((identity) => resolveRealtimeReadModelInvalidation(changes, identity))
.reduce(mergeRealtimeReadModelInvalidations); .reduce(mergeRealtimeReadModelInvalidations);
if (!hasRealtimeReadModelInvalidation(invalidation)) return null; if (!hasRealtimeReadModelInvalidation(invalidation)) return null;
return { type: 'readModelInvalidated', invalidation }; return { type: 'readModelInvalidated', invalidation, refreshGrant: createRefreshGrant() };
}; };
+74 -14
View File
@@ -10,6 +10,12 @@ import {
type DashboardSourceSlice, type DashboardSourceSlice,
} from '../../services/dashboardSourceRevision.js'; } from '../../services/dashboardSourceRevision.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.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 { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js'; import { getGeneralContext } from '../general/index.js';
import { getTurnCommandTable } from '../turns/index.js'; import { getTurnCommandTable } from '../turns/index.js';
@@ -39,6 +45,31 @@ const zContextBundleInput = z.object({
forceSnapshot: z.boolean().optional(), 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 <T>(options: { const createDashboardSliceDelta = async <T>(options: {
included: boolean; included: boolean;
sourceState: DashboardSourceRevisionState | null; sourceState: DashboardSourceRevisionState | null;
@@ -54,17 +85,7 @@ const createDashboardSliceDelta = async <T>(options: {
} }
const sourceRevision = options.sourceState?.sourceRevisions[options.slice]; const sourceRevision = options.sourceState?.sourceRevisions[options.slice];
if ( if (!requiresDashboardProjection(options) && options.knownContent !== undefined) {
sourceRevision !== undefined &&
options.knownContent !== undefined &&
canUseDashboardSourceRevision({
state: options.sourceState,
slice: options.slice,
knownContent: options.knownContent,
knownSource: options.knownSource,
forceSnapshot: options.forceSnapshot,
})
) {
return { return {
kind: 'unchanged', kind: 'unchanged',
revision: options.knownContent, revision: options.knownContent,
@@ -102,9 +123,48 @@ export const dashboardRouter = router({
)?.id ?? )?.id ??
null; null;
} }
const sourceState = generalId let sourceState = generalId ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null;
? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) const buildSliceRequests = (): DashboardSliceRequest[] => [
: null; {
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([ const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
createDashboardSliceDelta({ createDashboardSliceDelta({
+26 -2
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; 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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
@@ -18,6 +18,11 @@ import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js'; import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import {
consumeRealtimeAccessGrantHeader,
createRealtimeAccessGrant,
registerRealtimeAccessGrant,
} from './auth/realtimeAccessGrant.js';
import { appRouter } from './router.js'; import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
@@ -222,6 +227,13 @@ export const createGameApiServer = async () => {
uploadPublicUrl: config.uploadPublicUrl, uploadPublicUrl: config.uploadPublicUrl,
contentImageUpload, contentImageUpload,
auth, auth,
realtimeAccessGranted: await consumeRealtimeAccessGrantHeader(
redis.client,
req.headers[REALTIME_ACCESS_GRANT_HEADER],
auth,
config.profileName,
config.gameTokenSecret
),
...(auth && token ? { accessToken: token } : {}), ...(auth && token ? { accessToken: token } : {}),
accessTokenStore, accessTokenStore,
flushStore, flushStore,
@@ -299,8 +311,20 @@ export const createGameApiServer = async () => {
identities.push(nextIdentity); identities.push(nextIdentity);
viewerIdentity = 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 (!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( sendFrame(
formatSseFrame({ formatSseFrame({
event: publicEvent.type, event: publicEvent.type,
+4 -3
View File
@@ -25,6 +25,9 @@ export const accessPageWeights: Record<AccessPage, number> = {
'npc-control': 1, '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 = { export const generalAccessEndpointWeights = {
'world.getGeneralDirectory': 2, 'world.getGeneralDirectory': 2,
'public.getNpcList': 2, 'public.getNpcList': 2,
@@ -369,9 +372,7 @@ export const upsertGeneralAccess = async (
` `
); );
await writeReadModelChangeJournal(transaction, [ await writeReadModelChangeJournal(transaction, [{ domain: 'access.general', entityId: input.generalId }]);
{ domain: 'access.general', entityId: input.generalId },
]);
}); });
}; };
+50 -1
View File
@@ -4,7 +4,7 @@ import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js'; 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 = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
@@ -141,6 +141,55 @@ const contextOnly = {
}; };
describe('dashboardRouter.getContextBundleDelta', () => { 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 () => { it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
const fixture = buildContext(true); const fixture = buildContext(true);
const caller = dashboardRouter.createCaller(fixture.context); const caller = dashboardRouter.createCaller(fixture.context);
@@ -58,12 +58,25 @@ integration('general access tracking persistence', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number; let worldStateId: number;
let previousCoverageVersion: number | null;
beforeAll(async () => { beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! }); const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); 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({ await db.generalAccessLog.deleteMany({
where: { where: {
generalId: { generalId: {
@@ -126,6 +139,14 @@ integration('general access tracking persistence', () => {
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } }); await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.worldState.deleteMany({ where: { id: worldStateId } }); 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?.(); await closeDb?.();
}); });
@@ -380,6 +401,10 @@ integration('general access tracking persistence', () => {
scenario: 'default', scenario: 'default',
}, },
profileStatusSource: { get: async () => 'RUNNING' as const }, profileStatusSource: { get: async () => 'RUNNING' as const },
redis: {
get: async () => null,
set: async () => 'OK',
},
} as unknown as GameApiContext; } as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context); const boundaryCaller = endpointBoundaryRouter.createCaller(context);
@@ -387,12 +412,52 @@ integration('general access tracking persistence', () => {
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined(); await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); 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({ await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
}); });
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); 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( await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({ ).resolves.toMatchObject({
+14 -10
View File
@@ -3,9 +3,15 @@ import { describe, expect, it } from 'vitest';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; 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 viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({ const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
type: 'turnCompleted', type: 'turnCompleted',
@@ -42,6 +48,7 @@ describe('public realtime event privacy boundary', () => {
expect(publicEvent).toEqual({ expect(publicEvent).toEqual({
type: 'readModelInvalidated', type: 'readModelInvalidated',
refreshGrant,
invalidation: { invalidation: {
context: true, context: true,
lobby: false, lobby: false,
@@ -99,6 +106,7 @@ describe('public realtime event privacy boundary', () => {
) )
).toEqual({ ).toEqual({
type: 'readModelInvalidated', type: 'readModelInvalidated',
refreshGrant,
invalidation: { invalidation: {
context: true, context: true,
lobby: true, lobby: true,
@@ -119,6 +127,7 @@ describe('public realtime event privacy boundary', () => {
expect(publicEvent).toEqual({ expect(publicEvent).toEqual({
type: 'readModelInvalidated', type: 'readModelInvalidated',
refreshGrant,
invalidation: { invalidation: {
context: false, context: false,
lobby: false, lobby: false,
@@ -146,7 +155,7 @@ describe('public realtime event privacy boundary', () => {
senderId: 99, 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(); 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]); 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(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u);
expect( expect(
toPublicRealtimeEvent( toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, [viewer])
{ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] },
[viewer]
)
).toBeNull(); ).toBeNull();
}); });
@@ -195,9 +201,7 @@ describe('public realtime event privacy boundary', () => {
}, },
}; };
expect( expect(toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])).toMatchObject({
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
).toMatchObject({
type: 'readModelInvalidated', type: 'readModelInvalidated',
invalidation: { invalidation: {
context: true, context: true,
@@ -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<string>();
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
);
});
});
+31 -3
View File
@@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; 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) => const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
@@ -63,6 +65,7 @@ type NavigationFixture = {
boardAccessKind: string | null; boardAccessKind: string | null;
}>; }>;
dashboardRequests?: DashboardBundleInput[]; dashboardRequests?: DashboardBundleInput[];
dashboardGrantHeaders?: Array<string | null>;
}; };
type JsonPatchOperation = { type JsonPatchOperation = {
@@ -384,7 +387,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
} }
if (operation === 'dashboard.getContextBundleDelta') { if (operation === 'dashboard.getContextBundleDelta') {
state.generalMeCalls += 1; 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( return errorResponse(
operation, operation,
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' + '접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
@@ -588,7 +597,15 @@ const installRealtimeHarness = async (page: Page) => {
configurable: true, configurable: true,
value: (type: string, payload: unknown) => { value: (type: string, payload: unknown) => {
TestEventSource.latest?.dispatchEvent( 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) ?? {}),
}),
})
); );
}, },
}); });
@@ -2387,6 +2404,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toBe(true); .toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null);
const operationsBeforeTournament = state.operations.length; const operationsBeforeTournament = state.operations.length;
state.stage = 1; state.stage = 1;
@@ -2396,6 +2414,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']); .toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u); await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant);
await page.evaluate(() => { await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]'); const general = document.querySelector('[data-main-target="general"]');
@@ -2708,7 +2727,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu
.toBe(true); .toBe(true);
const operationsBeforeLimit = state.operations.length; 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(page.getByRole('alert')).toContainText('접속 제한중입니다.');
await expect await expect
@@ -2731,6 +2758,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
) )
.toBe(true); .toBe(true);
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
}); });
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => { test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
+48 -32
View File
@@ -20,6 +20,7 @@ import {
} from '../utils/dashboardReadModel'; } from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -519,27 +520,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const fetchContextBundlePatch = async ( const fetchContextBundlePatch = async (
include: DashboardContextBundleInclude, include: DashboardContextBundleInclude,
forceSnapshot = false forceSnapshot = false,
refreshGrant?: string
): Promise<DashboardReadModelPatch> => { ): Promise<DashboardReadModelPatch> => {
const queryOptions = createRealtimeRequestOptions(refreshGrant);
const request = (force: boolean) => const request = (force: boolean) =>
trpc.dashboard.getContextBundleDelta.query({ trpc.dashboard.getContextBundleDelta.query(
include, {
known: force include,
? undefined known: force
: { ? undefined
...(contextRevision ? { context: contextRevision } : {}), : {
...(commandTableRevision ? { commandTable: commandTableRevision } : {}), ...(contextRevision ? { context: contextRevision } : {}),
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}), ...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
}, ...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
knownSource: force },
? undefined knownSource: force
: { ? undefined
...(contextSourceRevision ? { context: contextSourceRevision } : {}), : {
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}), ...(contextSourceRevision ? { context: contextSourceRevision } : {}),
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}), ...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
}, ...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
forceSnapshot: force || undefined, },
}); forceSnapshot: force || undefined,
},
queryOptions
);
return resolveWithReadModelSnapshotFallback({ return resolveWithReadModelSnapshotFallback({
request, 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; const id = generalId.value;
if (!id) { if (!id) {
return; return;
@@ -688,37 +694,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (plan.records) recordsError.value = null; if (plan.records) recordsError.value = null;
if (plan.frontStatus) frontStatusError.value = null; if (plan.frontStatus) frontStatusError.value = null;
try { try {
const queryOptions = createRealtimeRequestOptions(refreshGrant);
// Every automatic refresh crosses this access-limit gate before // Every automatic refresh crosses this access-limit gate before
// any selected follow-up query starts. An all-false bundle is an // any selected follow-up query starts. An all-false bundle is an
// access-only check and does not project general context. // 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; 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 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); : Promise.resolve(undefined);
const contactsPromise = plan.contacts const contactsPromise = plan.contacts
? trpc.messages.getContacts.query({ generalId: id }) ? trpc.messages.getContacts.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined); : Promise.resolve(undefined);
const reservedPromise = plan.reservedTurns const reservedPromise = plan.reservedTurns
? trpc.turns.reserved.getGeneral.query({ generalId: id }) ? trpc.turns.reserved.getGeneral.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined); : Promise.resolve(undefined);
const recordsPromise = plan.records const recordsPromise = plan.records
? trpc.general.getRecentRecords ? trpc.general.getRecentRecords
.query({ lastGeneralRecordId, lastWorldHistoryId }) .query({ lastGeneralRecordId, lastWorldHistoryId }, queryOptions)
.catch((err: unknown) => { .catch((err: unknown) => {
recordsError.value = resolveErrorMessage(err); recordsError.value = resolveErrorMessage(err);
return null; return null;
}) })
: Promise.resolve(undefined); : Promise.resolve(undefined);
const frontPromise = plan.frontStatus const frontPromise = plan.frontStatus
? trpc.general.getFrontStatus.query().catch((err: unknown) => { ? trpc.general.getFrontStatus.query(undefined, queryOptions).catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err); frontStatusError.value = resolveErrorMessage(err);
return null; return null;
}) })
: Promise.resolve(undefined); : Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query().catch(() => undefined) ? trpc.tournament.getState.query(undefined, queryOptions).catch(() => undefined)
: Promise.resolve(undefined); : Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([ 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 readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels);
const refreshMessages = async () => { const refreshMessages = async (refreshGrant?: string) => {
const id = generalId.value; const id = generalId.value;
if (!id) { if (!id) {
return; return;
} }
try { 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; const patch = { messages: nextMessages } satisfies DashboardReadModelPatch;
applyDashboardPatch(patch); applyDashboardPatch(patch);
publishDashboardPatch(patch); publishDashboardPatch(patch);
@@ -1145,7 +1161,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'readModelInvalidated') { if (!payload || payload.type !== 'readModelInvalidated') {
return; return;
} }
readModelRefreshQueue.request(payload.invalidation); readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
}); });
source.addEventListener('messagesInvalidated', (event) => { source.addEventListener('messagesInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
@@ -1153,7 +1169,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'messagesInvalidated') { if (!payload || payload.type !== 'messagesInvalidated') {
return; return;
} }
void refreshMessages(); void refreshMessages(payload.refreshGrant);
}); });
// Rolling deployment fallback: an older API may still expose internal // Rolling deployment fallback: an older API may still expose internal
@@ -20,9 +20,7 @@ export const resolveDashboardRefreshPlan = (
identity: DashboardReadModelIdentity identity: DashboardReadModelIdentity
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
export const resolveDashboardContextBundleInclude = ( export const resolveDashboardContextBundleInclude = (plan: DashboardRefreshPlan): DashboardContextBundleInclude => ({
plan: DashboardRefreshPlan
): DashboardContextBundleInclude => ({
context: plan.context, context: plan.context,
commandTable: plan.commands, commandTable: plan.commands,
boardAccess: plan.boardAccess, boardAccess: plan.boardAccess,
@@ -31,12 +29,12 @@ export const resolveDashboardContextBundleInclude = (
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue { export interface MergedReadModelRefreshQueue {
request(invalidation: RealtimeReadModelInvalidation): void; request(invalidation: RealtimeReadModelInvalidation, refreshGrant: string): void;
cancelPending(): void; cancelPending(): void;
} }
export const createMergedReadModelRefreshQueue = ( export const createMergedReadModelRefreshQueue = (
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>, refresh: (invalidation: RealtimeReadModelInvalidation, refreshGrant: string) => Promise<void>,
options: { options: {
minIntervalMs?: number; minIntervalMs?: number;
now?: () => number; now?: () => number;
@@ -49,6 +47,7 @@ export const createMergedReadModelRefreshQueue = (
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let pending = createEmptyRealtimeReadModelInvalidation(); let pending = createEmptyRealtimeReadModelInvalidation();
let pendingRefreshGrant = '';
let hasPending = false; let hasPending = false;
let running = false; let running = false;
let timer: TimerHandle | null = null; let timer: TimerHandle | null = null;
@@ -65,11 +64,13 @@ export const createMergedReadModelRefreshQueue = (
return; return;
} }
const next = pending; const next = pending;
const nextRefreshGrant = pendingRefreshGrant;
pending = createEmptyRealtimeReadModelInvalidation(); pending = createEmptyRealtimeReadModelInvalidation();
pendingRefreshGrant = '';
hasPending = false; hasPending = false;
running = true; running = true;
lastStartedAt = now(); lastStartedAt = now();
void refresh(next).finally(() => { void refresh(next, nextRefreshGrant).finally(() => {
running = false; running = false;
schedule(); schedule();
}); });
@@ -77,14 +78,16 @@ export const createMergedReadModelRefreshQueue = (
}; };
return { return {
request: (invalidation) => { request: (invalidation, refreshGrant) => {
pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation; pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
pendingRefreshGrant = refreshGrant;
hasPending = true; hasPending = true;
schedule(); schedule();
}, },
cancelPending: () => { cancelPending: () => {
hasPending = false; hasPending = false;
pending = createEmptyRealtimeReadModelInvalidation(); pending = createEmptyRealtimeReadModelInvalidation();
pendingRefreshGrant = '';
if (timer !== null) { if (timer !== null) {
clearTimer(timer); clearTimer(timer);
timer = null; timer = null;
@@ -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, unknown> }>
): 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;
};
+8 -2
View File
@@ -1,5 +1,7 @@
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; 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 => { const getGameToken = (): string | null => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -13,9 +15,13 @@ export const trpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
headers() { headers({ opList }) {
const token = getGameToken(); const token = getGameToken();
return token ? { authorization: `Bearer ${token}` } : {}; const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
return {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(refreshGrant ? { [REALTIME_ACCESS_GRANT_HEADER]: refreshGrant } : {}),
};
}, },
}), }),
], ],
@@ -207,10 +207,10 @@ void test('merges browser-safe boolean invalidations and starts at most once per
let nowMs = 0; let nowMs = 0;
let nextTimerId = 1; let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; at: number }>(); const timers = new Map<number, { callback: () => void; at: number }>();
const observed: Array<{ context: boolean; records: boolean }> = []; const observed: Array<{ context: boolean; records: boolean; refreshGrant: string }> = [];
const queue = createMergedReadModelRefreshQueue( const queue = createMergedReadModelRefreshQueue(
async (invalidation) => { async (invalidation, refreshGrant) => {
observed.push({ context: invalidation.context, records: invalidation.records }); observed.push({ context: invalidation.context, records: invalidation.records, refreshGrant });
}, },
{ {
minIntervalMs: 1_000, 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(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((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(), context: true }, 'grant-b');
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }, 'grant-c');
nowMs = 999; nowMs = 999;
runDueTimers(); runDueTimers();
assert.equal(observed.length, 1); assert.equal(observed.length, 1);
@@ -246,7 +246,7 @@ void test('merges browser-safe boolean invalidations and starts at most once per
runDueTimers(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [ assert.deepEqual(observed, [
{ context: true, records: false }, { context: true, records: false, refreshGrant: 'grant-a' },
{ context: true, records: true }, { context: true, records: true, refreshGrant: 'grant-c' },
]); ]);
}); });
@@ -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
);
});
+7 -5
View File
@@ -226,18 +226,20 @@ export interface TournamentChangedEvent {
export interface ReadModelInvalidatedEvent { export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated'; type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation; invalidation: RealtimeReadModelInvalidation;
/** Opaque, short-lived proof that the server initiated this refresh. */
refreshGrant: string;
} }
export interface MessagesInvalidatedEvent { export interface MessagesInvalidatedEvent {
type: 'messagesInvalidated'; 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. */ /** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent = export type RealtimeEvent =
| TurnCompletedEvent TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent | TournamentChangedEvent;
| ReadModelChangedEvent
| MessageCreatedEvent
| MessagesChangedEvent
| TournamentChangedEvent;