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