feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+79 -10
View File
@@ -10,6 +10,7 @@ import type { GatewayApiContext } from '../context.js';
import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
const zSessionToken = z.string().min(1);
const MAX_ICON_BYTES = 50 * 1024;
@@ -37,13 +38,15 @@ const decodeImage = (input: string): Buffer => {
return buffer;
};
const sameUtcDate = (left: Date, right: Date): boolean =>
left.getUTCFullYear() === right.getUTCFullYear() &&
left.getUTCMonth() === right.getUTCMonth() &&
left.getUTCDate() === right.getUTCDate();
const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000;
export const kstDayStart = (value: Date): Date => {
const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS);
return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS);
};
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
}
};
@@ -59,8 +62,34 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
};
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
if (user.imageServer !== 1 || user.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`;
const icon = resolveEffectiveAccountIcon(user);
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
};
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
(await ctx.profileStatus.listLobbyProfiles({ userId }))
.filter(
(profile) => (profile.status === 'RUNNING' || profile.status === 'PREOPEN') && profile.runtime.apiRunning
)
.map(({ profileName, profile, apiPort, korName }) => ({
profileName,
profile,
apiPort,
korName,
}));
const publishIconFlush = async (
ctx: GatewayApiContext,
userId: string,
reason: 'account-icon-changed' | 'account-icon-deleted'
): Promise<boolean> => {
try {
await ctx.flushPublisher.publishUserFlush(userId, reason);
return true;
} catch {
return false;
}
};
export const accountRouter = router({
@@ -136,6 +165,7 @@ export const accountRouter = router({
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
@@ -154,17 +184,56 @@ export const accountRouter = router({
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
await fs.mkdir(ctx.userIconDir, { recursive: true });
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
await ctx.users.updateIcon(user.id, filename, 1, now);
let revision: string | null;
try {
revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true);
} catch (error) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
throw error;
}
if (!revision) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
});
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
revision,
profiles,
flushPublished,
};
}),
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
await ctx.users.updateIcon(user.id, 'default.jpg', 0, now);
return { ok: true, iconUrl: null };
const profiles = await listIconSyncProfiles(ctx, user.id);
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false);
if (!revision) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
});
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
return {
ok: true,
iconUrl: null,
revision,
profiles,
flushPublished,
};
}),
prepareIconSync: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return {
iconUrl: buildIconUrl(ctx, user),
projection: resolveEffectiveAccountIcon(user),
profiles: await listIconSyncProfiles(ctx, user.id),
};
}),
});
+28 -18
View File
@@ -260,16 +260,17 @@ const zServerRestriction = z.object({
notes: z.string().max(2000).nullable().optional(),
});
const zSanctionsPatch = z.object({
bannedUntil: z.string().datetime().nullable().optional(),
mutedUntil: z.string().datetime().nullable().optional(),
suspendedUntil: z.string().datetime().nullable().optional(),
warningCount: z.number().int().min(0).nullable().optional(),
flags: z.array(z.string().min(1)).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
profileIconResetAt: z.string().datetime().nullable().optional(),
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
});
const zSanctionsPatch = z
.object({
bannedUntil: z.string().datetime().nullable().optional(),
mutedUntil: z.string().datetime().nullable().optional(),
suspendedUntil: z.string().datetime().nullable().optional(),
warningCount: z.number().int().min(0).nullable().optional(),
flags: z.array(z.string().min(1)).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
})
.strict();
const zLocalAccountInput = z.object({
username: z.string().min(2).max(32),
@@ -332,8 +333,6 @@ const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): Use
applyField('warningCount', patch.warningCount);
applyField('flags', patch.flags);
applyField('notes', patch.notes);
applyField('profileIconResetAt', patch.profileIconResetAt);
if (patch.serverRestrictions !== undefined) {
if (patch.serverRestrictions === null) {
delete next.serverRestrictions;
@@ -495,6 +494,7 @@ export const adminRouter = router({
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
profileIconResetAt: user.profileIconResetAt,
createdAt: user.createdAt,
};
}),
@@ -611,12 +611,22 @@ export const adminRouter = router({
message: 'User not found.',
});
}
const next = applySanctionsPatch(user.sanctions, {
profileIconResetAt: new Date().toISOString(),
});
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset');
return { profileIconResetAt: next.profileIconResetAt };
const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date());
if (!profileIconResetAt) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found.',
});
}
let flushPublished = true;
try {
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset', {
iconRevision: profileIconResetAt,
});
} catch {
flushPublished = false;
}
return { profileIconResetAt, flushPublished };
}),
forceDelete: userAdminProcedure
.input(
@@ -0,0 +1,86 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { isCanonicalIsoTimestamp } from '@sammo-ts/common';
import type { UserRepository } from './userRepository.js';
import { resolveEffectiveAccountIcon } from './accountIconProjection.js';
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1';
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
const candidate = Array.isArray(provided) ? provided[0] : provided;
if (!candidate) {
return false;
}
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
};
const parseUserIds = (body: unknown): string[] | null => {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return null;
}
const record = body as Record<string, unknown>;
if (Object.keys(record).length !== 1 || !Array.isArray(record.userIds)) {
return null;
}
if (record.userIds.length === 0 || record.userIds.length > 500) {
return null;
}
const userIds = record.userIds.filter(
(value): value is string => typeof value === 'string' && value.length > 0 && value.length <= 128
);
return userIds.length === record.userIds.length && new Set(userIds).size === userIds.length ? userIds : null;
};
export const registerAccountIconInternalRoute = (
app: FastifyInstance,
options: {
users: UserRepository;
secret: string;
}
): void => {
app.get<{ Params: { userId: string } }>('/internal/account-icons/:userId', async (request, reply) => {
void reply.header('Cache-Control', 'no-store');
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const user = await options.users.findById(request.params.userId);
if (!user) {
await reply.status(404).send({ ok: false, error: 'not_found' });
return;
}
await reply.send(resolveEffectiveAccountIcon(user));
});
app.post<{ Body: unknown }>('/internal/account-icon-resets', async (request, reply) => {
void reply.header('Cache-Control', 'no-store');
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const userIds = parseUserIds(request.body);
if (!userIds) {
await reply.status(400).send({ ok: false, error: 'invalid_request' });
return;
}
const users = await options.users.findByIds(userIds);
const byId = new Map(users.map((user) => [user.id, user]));
const resets = userIds.flatMap((userId) => {
const user = byId.get(userId);
const resetRevision = user?.profileIconResetAt;
if (!user || !resetRevision || !isCanonicalIsoTimestamp(resetRevision)) {
return [];
}
return [{ userId, resetRevision, current: resolveEffectiveAccountIcon(user) }];
});
await reply.send({ resets });
});
};
@@ -0,0 +1,18 @@
import { resolveAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common';
import type { UserRecord } from './userRepository.js';
export const resolveEffectiveAccountIcon = (
user: Pick<
UserRecord,
'createdAt' | 'picture' | 'imageServer' | 'iconUpdatedAt' | 'iconRevision' | 'profileIconResetAt'
>
): AccountIconProjection =>
resolveAccountIconProjection({
createdAt: user.createdAt,
picture: user.picture,
imageServer: user.imageServer,
...(user.iconUpdatedAt ? { iconUpdatedAt: user.iconUpdatedAt } : {}),
...(user.iconRevision ? { iconRevision: user.iconRevision } : {}),
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
});
+4 -2
View File
@@ -2,10 +2,11 @@ export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
reason?: string;
iconRevision?: string;
}
export interface GatewayFlushPublisher {
publishUserFlush(userId: string, reason?: string): Promise<void>;
publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void>;
}
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
@@ -17,11 +18,12 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
this.channel = channel;
}
async publishUserFlush(userId: string, reason?: string): Promise<void> {
async publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void> {
const payload: GatewayUserFlushEvent = {
userId,
flushedAt: new Date().toISOString(),
reason,
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
};
await this.client.publish(this.channel, JSON.stringify(payload));
}
@@ -18,6 +18,10 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
return null;
},
async findByIds(ids: string[]): Promise<UserRecord[]> {
const requested = new Set(ids);
return [...usersByName.values()].filter((user) => requested.has(user.id));
},
async findByUsername(username: string): Promise<UserRecord | null> {
return usersByName.get(username) ?? null;
},
@@ -149,11 +153,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
user.picture = picture;
user.imageServer = imageServer;
user.iconUpdatedAt = updatedAt.toISOString();
user.iconRevision = updatedAt.toISOString();
return;
}
}
throw new Error('User not found.');
},
async updateIconForDay(
userId: string,
picture: string,
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
): Promise<string | null> {
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) {
return null;
}
const previousRevision = Math.max(
new Date(user.createdAt).getTime(),
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
);
const revision = new Date(Math.max(updatedAt.getTime(), previousRevision + 1)).toISOString();
user.picture = picture;
user.imageServer = imageServer;
if (consumeDailyQuota) {
user.iconUpdatedAt = updatedAt.toISOString();
}
user.iconRevision = revision;
return revision;
}
throw new Error('User not found.');
},
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
const previousRevision = Math.max(
new Date(user.createdAt).getTime(),
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
);
const revision = new Date(Math.max(requestedAt.getTime(), previousRevision + 1)).toISOString();
user.iconRevision = revision;
user.profileIconResetAt = revision;
return revision;
}
return null;
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -1,4 +1,4 @@
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js';
@@ -44,6 +44,8 @@ const mapUser = (row: {
picture: string;
imageServer: number;
iconUpdatedAt: Date | null;
iconRevision: Date | null;
profileIconResetAt: Date | null;
thirdPartyUse: boolean;
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
@@ -65,6 +67,8 @@ const mapUser = (row: {
picture: row.picture,
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
iconRevision: row.iconRevision?.toISOString(),
profileIconResetAt: row.profileIconResetAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
@@ -91,6 +95,15 @@ export const createPostgresUserRepository = (
});
return row ? mapUser(row) : null;
},
async findByIds(ids: string[]): Promise<UserRecord[]> {
if (ids.length === 0) {
return [];
}
const rows = await prisma.appUser.findMany({
where: { id: { in: ids } },
});
return rows.map(mapUser);
},
async findByUsername(username: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({
where: {
@@ -219,9 +232,81 @@ export const createPostgresUserRepository = (
picture,
imageServer,
iconUpdatedAt: updatedAt,
iconRevision: updatedAt,
},
});
},
async updateIconForDay(
userId: string,
picture: string,
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
): Promise<string | null> {
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
UPDATE "app_user"
SET
"picture" = ${picture},
"image_server" = ${imageServer},
"icon_updated_at" = CASE
WHEN ${consumeDailyQuota} THEN ${updatedAt}
ELSE "icon_updated_at"
END,
"icon_revision" = GREATEST(
${updatedAt},
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
)
WHERE "id" = ${userId}
AND (
"picture" = 'default.jpg'
OR "icon_updated_at" IS NULL
OR "icon_updated_at" < ${dayStart}
)
RETURNING "icon_revision" AS "iconRevision"
`);
return rows[0]?.iconRevision.toISOString() ?? null;
},
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{
iconRevision: Date | null;
iconUpdatedAt: Date | null;
createdAt: Date;
profileIconResetAt: Date | null;
}>
>(GatewayPrisma.sql`
SELECT
"icon_revision" AS "iconRevision",
"icon_updated_at" AS "iconUpdatedAt",
"created_at" AS "createdAt",
"profile_icon_reset_at" AS "profileIconResetAt"
FROM "app_user"
WHERE "id" = ${userId}
FOR UPDATE
`);
const row = rows[0];
if (!row) {
return null;
}
const previous = Math.max(
row.createdAt.getTime(),
row.iconUpdatedAt?.getTime() ?? 0,
row.iconRevision?.getTime() ?? 0,
row.profileIconResetAt?.getTime() ?? 0
);
const revision = new Date(Math.max(requestedAt.getTime(), previous + 1));
await tx.appUser.update({
where: { id: userId },
data: {
iconRevision: revision,
profileIconResetAt: revision,
},
});
return revision.toISOString();
});
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
+12 -1
View File
@@ -11,6 +11,8 @@ export interface UserRecord {
picture: string;
imageServer: number;
iconUpdatedAt?: string;
iconRevision?: string;
profileIconResetAt?: string;
thirdPartyUse: boolean;
termsAcceptedAt?: string;
privacyAcceptedAt?: string;
@@ -42,7 +44,6 @@ export interface UserSanctions {
warningCount?: number;
flags?: string[];
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, UserServerRestriction>;
legacyPenalty?: Record<string, unknown>;
}
@@ -82,6 +83,7 @@ export interface CreateUserInput {
export interface UserRepository {
findById(id: string): Promise<UserRecord | null>;
findByIds(ids: string[]): Promise<UserRecord[]>;
findByUsername(username: string): Promise<UserRecord | null>;
findByDisplayName(displayName: string): Promise<UserRecord | null>;
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
@@ -102,6 +104,15 @@ export interface UserRepository {
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
updateIconForDay(
userId: string,
picture: string,
imageServer: number,
updatedAt: Date,
dayStart: Date,
consumeDailyQuota: boolean
): Promise<string | null>;
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
deleteUser(userId: string): Promise<void>;
+7 -1
View File
@@ -11,6 +11,7 @@ export interface GatewayApiConfig {
sessionTtlSeconds: number;
gameSessionTtlSeconds: number;
gameTokenSecret: string;
gatewayInternalApiUrl: string;
oauthSessionTtlSeconds: number;
kakaoRestKey: string;
kakaoAdminKey?: string;
@@ -36,6 +37,7 @@ export interface GatewayOrchestratorConfig {
dbSchema: string;
redisKeyPrefix: string;
gameTokenSecret: string;
gatewayInternalApiUrl: string;
orchestratorReconcileIntervalMs: number;
orchestratorScheduleIntervalMs: number;
orchestratorBuildIntervalMs: number;
@@ -64,9 +66,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
}
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
return {
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
port: parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
port,
trpcPath: env.GATEWAY_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
redisKeyPrefix,
@@ -78,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
'GAME_SESSION_TTL_SECONDS'
),
gameTokenSecret: secret,
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${port}`,
oauthSessionTtlSeconds: parseNumberWithFallback(
env.OAUTH_SESSION_TTL_SECONDS,
10 * 60,
@@ -133,10 +137,12 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
throw new Error('GAME_TOKEN_SECRET is required for game server processes.');
}
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
const gatewayPort = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
return {
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
redisKeyPrefix,
gameTokenSecret: secret,
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${gatewayPort}`,
orchestratorReconcileIntervalMs: parseNumberWithFallback(
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
15000,
@@ -20,6 +20,7 @@ export interface GatewayProcessConfig {
workspaceRoot: string;
redisKeyPrefix: string;
gameTokenSecret: string;
gatewayInternalApiUrl: string;
baseEnv?: Record<string, string>;
}
@@ -350,6 +351,7 @@ export const buildProcessDefinitions = (
GAME_UPLOAD_PATH: `/${profile.profile}/api/uploads`,
GATEWAY_REDIS_PREFIX: config.redisKeyPrefix,
GAME_TOKEN_SECRET: config.gameTokenSecret,
GATEWAY_INTERNAL_API_URL: config.gatewayInternalApiUrl,
};
const daemonEnv = {
...baseEnv,
@@ -40,6 +40,7 @@ export const createGatewayOrchestrator = (
workspaceRoot,
redisKeyPrefix: config.redisKeyPrefix,
gameTokenSecret: config.gameTokenSecret,
gatewayInternalApiUrl: config.gatewayInternalApiUrl,
baseEnv,
},
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
+6 -2
View File
@@ -14,6 +14,7 @@ import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
const zUsername = z
.string()
@@ -655,6 +656,7 @@ export const appRouter = router({
});
}
const now = new Date();
const accountIcon = resolveEffectiveAccountIcon(user);
const payload = {
version: 1,
profile: gameSession.profile,
@@ -665,8 +667,10 @@ export const appRouter = router({
id: user.id,
username: user.username,
displayName: user.displayName,
picture: user.picture,
imageServer: user.imageServer,
picture: accountIcon.picture,
imageServer: accountIcon.imageServer,
iconUpdatedAt: accountIcon.revision,
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
roles: user.roles,
createdAt: user.createdAt,
+5
View File
@@ -24,6 +24,7 @@ import { RedisGatewaySessionService } from './auth/redisSessionService.js';
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
@@ -78,6 +79,10 @@ export const createGatewayApiServer = async () => {
prefix: '/user-icons/',
decorateReply: false,
});
registerAccountIconInternalRoute(app, {
users,
secret: config.gameTokenSecret,
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -0,0 +1,157 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const userId = 'e72680fd-0aed-4fdd-80d9-24f78d55676c';
const assertDedicatedSchema = (): void => {
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
}
};
integration('account icon daily PostgreSQL CAS', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let initialized = false;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
initialized = true;
closeDb = () => connector.disconnect();
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'icon-cas-integration',
displayName: '아이콘 CAS 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
picture: 'old.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
},
});
});
afterAll(async () => {
if (initialized) {
await db.appUser.deleteMany({ where: { id: userId } });
}
await closeDb?.();
});
it('allows exactly one concurrent update in the same KST day', async () => {
const users = createPostgresUserRepository(db);
const updatedAt = new Date('2026-07-31T15:00:00.000Z');
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
const results = await Promise.all([
users.updateIconForDay(userId, 'first.png', 1, updatedAt, kstDayStart, true),
users.updateIconForDay(userId, 'second.png', 1, updatedAt, kstDayStart, true),
]);
expect(results.filter(Boolean)).toHaveLength(1);
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
picture: expect.stringMatching(/^(first|second)\.png$/),
imageServer: 1,
iconUpdatedAt: updatedAt,
});
});
it('allows a default icon to upload again while revisions stay strictly increasing', async () => {
const users = createPostgresUserRepository(db);
const frozenNow = new Date('2026-08-01T03:00:00.000Z');
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
await db.appUser.update({
where: { id: userId },
data: {
picture: 'old.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-30T14:59:59.000Z'),
iconRevision: new Date('2026-08-01T03:00:00.000Z'),
},
});
const deletedRevision = await users.updateIconForDay(userId, 'default.jpg', 0, frozenNow, kstDayStart, false);
const uploadedRevision = await users.updateIconForDay(userId, 'again.png', 1, frozenNow, kstDayStart, true);
expect(deletedRevision).toBe('2026-08-01T03:00:00.001Z');
expect(uploadedRevision).toBe('2026-08-01T03:00:00.002Z');
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
picture: 'again.png',
iconUpdatedAt: frozenNow,
iconRevision: new Date('2026-08-01T03:00:00.002Z'),
});
});
it('uses UTC 15:00 as the KST date boundary', async () => {
const users = createPostgresUserRepository(db);
await db.appUser.update({
where: { id: userId },
data: {
picture: 'same-day.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-31T14:59:59.000Z'),
iconRevision: new Date('2026-07-31T14:59:59.000Z'),
},
});
await expect(
users.updateIconForDay(
userId,
'blocked.png',
1,
new Date('2026-07-31T14:59:59.999Z'),
new Date('2026-07-30T15:00:00.000Z'),
true
)
).resolves.toBeNull();
await expect(
users.updateIconForDay(
userId,
'allowed.png',
1,
new Date('2026-07-31T15:00:00.000Z'),
new Date('2026-07-31T15:00:00.000Z'),
true
)
).resolves.toBeTruthy();
});
it('serializes administrator reset revisions in a dedicated column', async () => {
const users = createPostgresUserRepository(db);
const frozenNow = new Date('2026-08-02T00:00:00.000Z');
await db.appUser.update({
where: { id: userId },
data: {
picture: 'custom.png',
imageServer: 1,
iconRevision: frozenNow,
profileIconResetAt: null,
sanctions: { warningCount: 1 },
},
});
const first = await users.resetProfileIcon(userId, frozenNow);
const second = await users.resetProfileIcon(userId, frozenNow);
expect(first).toBe('2026-08-02T00:00:00.001Z');
expect(second).toBe('2026-08-02T00:00:00.002Z');
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
profileIconResetAt: new Date('2026-08-02T00:00:00.002Z'),
iconRevision: new Date('2026-08-02T00:00:00.002Z'),
sanctions: { warningCount: 1 },
});
});
});
@@ -0,0 +1,103 @@
import { createHmac, randomUUID } from 'node:crypto';
import fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerAccountIconInternalRoute } from '../src/auth/accountIconInternalRoute.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
const secret = 'gateway-test-secret';
const token = createHmac('sha256', secret).update('sammo:account-icon-source:v1').digest('hex');
describe('account icon internal route', () => {
const app = fastify();
const users = createInMemoryUserRepository();
let userId = '';
beforeEach(async () => {
if (!app.hasRoute({ method: 'GET', url: '/internal/account-icons/:userId' })) {
registerAccountIconInternalRoute(app, { users, secret });
}
const user = await users.createUser({
username: `internal-${randomUUID()}`,
password: 'password',
displayName: `내부-${randomUUID()}`,
});
userId = user.id;
await users.updateIcon(userId, 'latest.png', 1, new Date('2026-07-31T09:00:00.000Z'));
});
afterEach(async () => {
if (userId) {
await users.deleteUser(userId);
}
});
it('requires the purpose-derived token and exposes only the projection', async () => {
const unauthorized = await app.inject({
method: 'GET',
url: `/internal/account-icons/${userId}`,
headers: { 'x-sammo-internal-token': secret },
});
expect(unauthorized.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: `/internal/account-icons/${userId}`,
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
});
expect(Object.keys(response.json()).sort()).toEqual(['imageServer', 'picture', 'revision']);
});
it('returns 404 for a missing account without leaking account fields', async () => {
const response = await app.inject({
method: 'GET',
url: `/internal/account-icons/${randomUUID()}`,
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
});
it('returns the durable reset marker even after a newer ordinary icon change', async () => {
const resetRevision = await users.resetProfileIcon(userId, new Date('2099-07-31T09:00:00.001Z'));
await users.updateIcon(userId, 'newer.png', 1, new Date('2099-07-31T09:00:00.002Z'));
const response = await app.inject({
method: 'POST',
url: '/internal/account-icon-resets',
headers: { 'x-sammo-internal-token': token },
payload: { userIds: [userId, randomUUID()] },
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({
resets: [
{
userId,
resetRevision,
current: {
revision: '2099-07-31T09:00:00.002Z',
picture: 'newer.png',
imageServer: 1,
},
},
],
});
const invalid = await app.inject({
method: 'POST',
url: '/internal/account-icon-resets',
headers: { 'x-sammo-internal-token': token },
payload: { userIds: [userId], extra: true },
});
expect(invalid.statusCode).toBe(400);
});
});
+40 -3
View File
@@ -33,7 +33,7 @@ const buildCaller = async (
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string }> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
const profile = {
profileName: 'che:2',
profile: 'che',
@@ -79,8 +79,12 @@ const buildCaller = async (
users,
sessions,
flushPublisher: {
publishUserFlush: async (userId, reason) => {
flushes.push({ userId, reason });
publishUserFlush: async (userId, reason, metadata) => {
flushes.push({
userId,
reason,
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
});
},
},
gameTokenSecret: 'test-secret',
@@ -397,4 +401,37 @@ describe('admin role non-escalation', () => {
{ userId: target.id, reason: 'admin-server-restriction' },
]);
});
it('keeps profile icon reset revisions monotonic and outside generic sanction patches', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'icon-reset-target',
password: 'secretpass',
displayName: 'Icon Reset Target',
});
const frozenNow = new Date(target.createdAt);
await harness.users.updateIcon(target.id, 'custom.png', 1, frozenNow);
const first = await harness.users.resetProfileIcon(target.id, frozenNow);
const second = await harness.users.resetProfileIcon(target.id, frozenNow);
expect(new Date(first!).getTime()).toBe(new Date(target.createdAt).getTime() + 1);
expect(new Date(second!).getTime()).toBe(new Date(first!).getTime() + 1);
await expect(
harness.caller.admin.users.updateSanctions({
userId: target.id,
patch: {
profileIconResetAt: null,
},
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id });
expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime());
expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt);
expect(harness.flushes.at(-1)).toEqual({
userId: target.id,
reason: 'admin-profile-icon-reset',
iconRevision: result.profileIconResetAt,
});
});
});
+199 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -16,14 +16,25 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
const buildCaller = (
options: {
userIconDir?: string;
localAccountGraceDays?: number;
flushError?: Error;
profileListError?: Error;
} = {}
) => {
const users = createInMemoryUserRepository();
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 3600,
gameSessionTtlSeconds: 600,
});
const flushPublisher = {
publishUserFlush: async () => {},
publishUserFlush: vi.fn(async () => {
if (options.flushError) {
throw options.flushError;
}
}),
};
const oauthSessions = new InMemoryOAuthSessionStore();
const kakaoClient = {
@@ -139,6 +150,11 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
color: '#fff',
}))
);
if (options.profileListError) {
profileStatus.listLobbyProfiles = async () => {
throw options.profileListError;
};
}
const passwordEnvelope = createPasswordEnvelopeService();
const requestHeaders: Record<string, string> = {};
const sealPassword = (password: string) => {
@@ -187,6 +203,7 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
oauthSessions,
users,
sessions,
flushPublisher,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -576,6 +593,7 @@ describe('gateway auth flow', () => {
roles: ['user', 'latest-role'],
picture: 'latest-owner.webp',
imageServer: 3,
iconUpdatedAt: '2026-07-30T12:00:00.000Z',
canUseGeneralPicture: false,
});
expect(payload?.sanctions).toMatchObject({
@@ -668,7 +686,9 @@ describe('account self service', () => {
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const { caller, users, sessions, flushPublisher } = buildCaller({
userIconDir: iconDir,
});
const user = await users.createUser({
username: 'icon-self',
password: 'current-password',
@@ -692,7 +712,9 @@ describe('account self service', () => {
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
expect(updated?.imageServer).toBe(1);
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
@@ -701,4 +723,177 @@ describe('account self service', () => {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('atomically allows only one icon change per KST day and removes the losing file', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-race',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#556677',
},
})
.png()
.toBuffer();
const attempts = await Promise.allSettled(
[1, 2].map(() =>
caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
})
)
);
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(await fs.readdir(iconDir)).toHaveLength(1);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('flushes an account icon deletion with selectable running profiles', async () => {
const { caller, users, sessions, flushPublisher } = buildCaller();
const user = await users.createUser({
username: 'icon-delete',
password: 'current-password',
});
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-30T12:00:00.000Z'));
const session = await sessions.createSession(user);
const result = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
const updated = await users.findById(user.id);
expect(result).toMatchObject({
ok: true,
iconUrl: null,
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
});
expect(updated).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
});
it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#667788',
},
})
.png()
.toBuffer();
try {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-31T14:59:59.000Z'));
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-kst',
password: 'current-password',
});
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
const session = await sessions.createSession(user);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
const changed = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
} finally {
vi.useRealTimers();
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('does not commit an icon when profile discovery fails before mutation', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-profile-failure-'));
try {
const { caller, users, sessions } = buildCaller({
userIconDir: iconDir,
profileListError: new Error('profile unavailable'),
});
const user = await users.createUser({
username: 'icon-profile-failure',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#778899' },
})
.png()
.toBuffer();
await expect(
caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
})
).rejects.toThrow('profile unavailable');
expect(await fs.readdir(iconDir)).toEqual([]);
expect(await users.findById(user.id)).toMatchObject({
picture: 'default.jpg',
imageServer: 0,
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('returns a recoverable success when flush publication fails after commit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-flush-failure-'));
try {
const { caller, users, sessions } = buildCaller({
userIconDir: iconDir,
flushError: new Error('redis unavailable'),
});
const user = await users.createUser({
username: 'icon-flush-failure',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#8899aa' },
})
.png()
.toBuffer();
const changed = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
expect(changed.flushPublished).toBe(false);
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
await expect(caller.account.prepareIconSync({ sessionToken: session.sessionToken })).resolves.toMatchObject(
{
projection: {
revision: changed.revision,
imageServer: 1,
},
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
}
);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
});
@@ -129,6 +129,7 @@ const createHarness = (
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
@@ -102,6 +102,7 @@ describe('buildProcessDefinitions', () => {
workspaceRoot: '/srv/sammo/main',
redisKeyPrefix: 'sammo:gateway',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
};
it('runs a built profile from its commit worktree', () => {
@@ -114,6 +115,7 @@ describe('buildProcessDefinitions', () => {
GAME_PROFILE_NAME: 'che:2',
GAME_TRPC_PATH: '/che/api/trpc',
GAME_API_EVENTS_PATH: '/che/api/events',
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
GAME_UPLOAD_PATH: '/che/api/uploads',
});
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
@@ -62,6 +62,7 @@ const createHarness = (
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
@@ -49,9 +49,7 @@ describe('password credential compatibility', () => {
password: 'current-password',
});
const userSalt = 'ref-user-salt';
const browserHash = createHash('sha512')
.update(`${globalSalt}current-password${globalSalt}`)
.digest('hex');
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');