feat: synchronize account icons across game profiles
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
export interface AccountIconProjection {
|
||||
revision: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
}
|
||||
|
||||
export const isCanonicalIsoTimestamp = (value: string): boolean => {
|
||||
const parsed = new Date(value);
|
||||
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
|
||||
};
|
||||
|
||||
export const parseAccountIconProjection = (value: unknown): AccountIconProjection => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Account icon projection must be an object.');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).length !== 3 ||
|
||||
!Object.hasOwn(record, 'revision') ||
|
||||
!Object.hasOwn(record, 'picture') ||
|
||||
!Object.hasOwn(record, 'imageServer') ||
|
||||
typeof record.revision !== 'string' ||
|
||||
!isCanonicalIsoTimestamp(record.revision) ||
|
||||
typeof record.picture !== 'string' ||
|
||||
record.picture.length === 0 ||
|
||||
typeof record.imageServer !== 'number' ||
|
||||
!Number.isSafeInteger(record.imageServer) ||
|
||||
record.imageServer < 0
|
||||
) {
|
||||
throw new Error('Account icon projection is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: record.revision,
|
||||
picture: record.picture,
|
||||
imageServer: record.imageServer,
|
||||
};
|
||||
};
|
||||
|
||||
const canonicalRevision = (value: string | undefined): string | null =>
|
||||
value && isCanonicalIsoTimestamp(value) ? value : null;
|
||||
|
||||
export const resolveAccountIconProjection = (input: {
|
||||
createdAt: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
}): AccountIconProjection => {
|
||||
const iconRevision =
|
||||
canonicalRevision(input.iconRevision) ??
|
||||
canonicalRevision(input.iconUpdatedAt) ??
|
||||
canonicalRevision(input.createdAt);
|
||||
if (!iconRevision) {
|
||||
throw new Error('User account icon revision is invalid.');
|
||||
}
|
||||
const resetRevision = canonicalRevision(input.profileIconResetAt);
|
||||
if (resetRevision && resetRevision >= iconRevision) {
|
||||
return {
|
||||
revision: resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
if (!input.picture || !Number.isSafeInteger(input.imageServer) || input.imageServer < 0) {
|
||||
throw new Error('User account icon is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: iconRevision,
|
||||
picture: input.picture,
|
||||
imageServer: input.imageServer,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import { isCanonicalIsoTimestamp } from './accountIconProjection.js';
|
||||
|
||||
export interface UserSanctions {
|
||||
bannedUntil?: string;
|
||||
mutedUntil?: string;
|
||||
@@ -7,7 +9,6 @@ export interface UserSanctions {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
@@ -26,6 +27,8 @@ export interface GatewayUserInfo {
|
||||
roles: string[];
|
||||
picture?: string;
|
||||
imageServer?: number;
|
||||
iconUpdatedAt?: string;
|
||||
profileIconResetAt?: string;
|
||||
canUseGeneralPicture?: boolean;
|
||||
createdAt?: string;
|
||||
legacyMemberNo?: number;
|
||||
@@ -90,6 +93,10 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.picture !== undefined && typeof user.picture !== 'string') ||
|
||||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
|
||||
(user.iconUpdatedAt !== undefined &&
|
||||
(typeof user.iconUpdatedAt !== 'string' || !isCanonicalIsoTimestamp(user.iconUpdatedAt))) ||
|
||||
(user.profileIconResetAt !== undefined &&
|
||||
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
|
||||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
|
||||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './turnDaemon/types.js';
|
||||
export * from './realtime/keys.js';
|
||||
export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
|
||||
@@ -205,6 +205,14 @@ export type TurnDaemonCommand =
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconRevision: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
requestId?: string;
|
||||
@@ -220,6 +228,7 @@ export type TurnDaemonCommand =
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
@@ -516,6 +525,18 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: false;
|
||||
code: 'CONFLICT' | 'PRECONDITION_FAILED';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
ok: true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAccountIconProjection } from '../src/auth/accountIconProjection.js';
|
||||
|
||||
describe('account icon projection', () => {
|
||||
it('resolves the current account icon from its durable revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an administrator reset win at the same or a later revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
profileIconResetAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ createdAt: 'not-a-date', picture: 'account.png', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: '', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: 'account.png', imageServer: -1 },
|
||||
])('rejects invalid durable account icon state: %j', (value) => {
|
||||
expect(() => resolveAccountIconProjection(value)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decryptGameSessionToken,
|
||||
encryptGameSessionToken,
|
||||
parseGameSessionTokenPayload,
|
||||
type GameSessionTokenPayload,
|
||||
} from '../src/auth/gameToken.js';
|
||||
|
||||
const buildPayload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-31T09:00:00.000Z',
|
||||
expiresAt: '2026-07-31T09:10:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '테스트',
|
||||
roles: ['user'],
|
||||
picture: 'account-icon.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: '2026-07-31T08:59:00.000Z',
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
describe('game session token account icon revision', () => {
|
||||
it('round-trips the canonical account icon revision', () => {
|
||||
const payload = buildPayload();
|
||||
const token = encryptGameSessionToken(payload, 'test-only-secret');
|
||||
|
||||
expect(decryptGameSessionToken(token, 'test-only-secret')).toEqual(payload);
|
||||
});
|
||||
|
||||
it.each([1, {}, '2026-07-31', '2026-07-31T08:59:00Z', 'not-a-date'])(
|
||||
'rejects a non-canonical icon revision: %j',
|
||||
(iconUpdatedAt) => {
|
||||
const payload = buildPayload() as unknown as {
|
||||
user: { iconUpdatedAt: unknown };
|
||||
};
|
||||
payload.user.iconUpdatedAt = iconUpdatedAt;
|
||||
|
||||
expect(parseGameSessionTokenPayload(payload)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user