fix: 갱신 비용에 따라 접속 점수를 기록
revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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() };
|
||||
};
|
||||
|
||||
@@ -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 <T>(options: {
|
||||
included: boolean;
|
||||
sourceState: DashboardSourceRevisionState | null;
|
||||
@@ -54,17 +85,7 @@ const createDashboardSliceDelta = async <T>(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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,9 @@ export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'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 }]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,12 +58,25 @@ integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | 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({
|
||||
|
||||
@@ -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<typeof convertPublicRealtimeEvent>[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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>;
|
||||
};
|
||||
|
||||
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) ?? {}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -2387,6 +2404,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;
|
||||
@@ -2396,6 +2414,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"]');
|
||||
@@ -2708,7 +2727,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
|
||||
@@ -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())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
|
||||
});
|
||||
|
||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||
|
||||
@@ -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<DashboardReadModelPatch> => {
|
||||
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<TournamentState | undefined> = 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
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
|
||||
export interface MergedReadModelRefreshQueue {
|
||||
request(invalidation: RealtimeReadModelInvalidation): void;
|
||||
request(invalidation: RealtimeReadModelInvalidation, refreshGrant: string): void;
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
export const createMergedReadModelRefreshQueue = (
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation, refreshGrant: string) => Promise<void>,
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<AppRouter>({
|
||||
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 } : {}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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<number, { callback: () => 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<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(), 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<void>((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' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user