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 {
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,
+7 -7
View File
@@ -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() };
};
+74 -14
View File
@@ -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({
+26 -2
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { buildGameEventChannel, 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,
+4 -3
View File
@@ -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 }]);
});
};
+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 { 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({
+14 -10
View File
@@ -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
);
});
});