merge: 최신 main의 갱신 비용 변경을 tRPC 전송에 통합

# Conflicts:
#	app/game-api/src/server.ts
#	app/game-frontend/e2e/mainNavigation.spec.ts
#	app/game-frontend/src/utils/trpc.ts
This commit is contained in:
2026-08-17 11:22:19 +00:00
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 -1
View File
@@ -6,6 +6,7 @@ import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
buildGameEventChannel,
REALTIME_ACCESS_GRANT_HEADER,
trpcJsonBodyHttpServerOptions,
type RealtimeViewerIdentity,
} from '@sammo-ts/common';
@@ -22,6 +23,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';
@@ -227,6 +233,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,
@@ -304,8 +317,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
);
});
});