feat: complete user-facing message and account APIs

This commit is contained in:
2026-07-25 11:39:47 +00:00
parent 579d0a8d4c
commit fbfaf8df81
26 changed files with 1272 additions and 525 deletions
+2
View File
@@ -26,6 +26,8 @@ GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026 GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
GATEWAY_USER_ICON_DIR=uploads/user-icons
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons
SESSION_TTL_SECONDS=604800 SESSION_TTL_SECONDS=604800
GAME_SESSION_TTL_SECONDS=21600 GAME_SESSION_TTL_SECONDS=21600
OAUTH_SESSION_TTL_SECONDS=600 OAUTH_SESSION_TTL_SECONDS=600
+1
View File
@@ -164,3 +164,4 @@ docker-compose.override.yml
docs/image-storage.md docs/image-storage.md
playwright-report/ playwright-report/
test-results/ test-results/
uploads/
+35
View File
@@ -23,6 +23,14 @@ interface MessageRow {
message: unknown; message: unknown;
} }
export interface StoredMessage {
id: number;
mailbox: number;
msgType: MessageType;
time: Date;
payload: MessagePayload;
}
const parsePayload = (value: unknown): MessagePayload => { const parsePayload = (value: unknown): MessagePayload => {
if (typeof value === 'string') { if (typeof value === 'string') {
return JSON.parse(value) as MessagePayload; return JSON.parse(value) as MessagePayload;
@@ -113,3 +121,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
return rows.map(toMessageView); return rows.map(toMessageView);
}; };
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id} AND valid_until > NOW()
LIMIT 1
`;
const row = rows[0];
if (!row) return null;
return {
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
payload: parsePayload(row.message),
};
};
export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: { validUntil: new Date() },
});
};
+117 -4
View File
@@ -14,11 +14,14 @@ import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '..
import { import {
fetchMessagesFromMailbox, fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox, fetchOldMessagesFromMailbox,
fetchMessageById,
invalidateMessages,
insertMessage, insertMessage,
type MessageView, type MessageView,
} from '../../messages/store.js'; } from '../../messages/store.js';
import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -42,7 +45,8 @@ export const messagesRouter = router({
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>; } satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([ const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
[
fetchMessagesFromMailbox({ fetchMessagesFromMailbox({
db: ctx.db, db: ctx.db,
mailbox: mailboxes.private, mailbox: mailboxes.private,
@@ -71,7 +75,9 @@ export const messagesRouter = router({
limit: 15, limit: 15,
fromSeq: sequence, fromSeq: sequence,
}), }),
]); ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
]
);
const messageBuckets: Record<MessageType, MessageView[]> = { const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages, private: privateMessages,
@@ -117,11 +123,118 @@ export const messagesRouter = router({
nationId: nationId, nationId: nationId,
generalName: general.name, generalName: general.name,
latestRead: { latestRead: {
diplomacy: 0, diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: 0, private: readState?.latestPrivateMessage ?? 0,
}, },
}; };
}), }),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({
select: { id: true, name: true, color: true, meta: true },
orderBy: { id: 'asc' },
}),
ctx.db.general.findMany({
where: { npcState: { lt: 2 } },
select: {
id: true,
name: true,
nationId: true,
officerLevel: true,
npcState: true,
meta: true,
penalty: true,
},
orderBy: { id: 'asc' },
}),
]);
const nationMeta = new Map(nations.map((nation) => [nation.id, nation.meta]));
const grouped = new Map<number, Array<[number, string, number]>>();
for (const general of generals) {
let flags = 0;
if (general.officerLevel === 12) flags |= 1;
if (general.npcState === 1) flags |= 2;
if (resolveNationPermission(general, nationMeta.get(general.nationId) ?? {}, false) === 4) flags |= 4;
const list = grouped.get(general.nationId) ?? [];
list.push([general.id, general.name, flags]);
grouped.set(general.nationId, list);
}
const nationList = [
{ id: 0, name: '재야', color: '#000000', meta: {} },
...nations.filter((nation) => nation.id !== 0),
];
return {
nation: nationList.map((nation) => ({
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name,
color: nation.color,
general: grouped.get(nation.id) ?? [],
})),
};
}),
readLatest: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: z.enum(['private', 'diplomacy']),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const privateValue = input.type === 'private' ? input.messageId : 0;
const diplomacyValue = input.type === 'diplomacy' ? input.messageId : 0;
await ctx.db.$executeRaw`
INSERT INTO message_read_state (
general_id,
latest_private_message,
latest_diplomacy_message,
updated_at
)
VALUES (${general.id}, ${privateValue}, ${diplomacyValue}, NOW())
ON CONFLICT (general_id) DO UPDATE SET
latest_private_message = GREATEST(
message_read_state.latest_private_message,
EXCLUDED.latest_private_message
),
latest_diplomacy_message = GREATEST(
message_read_state.latest_diplomacy_message,
EXCLUDED.latest_diplomacy_message
),
updated_at = NOW()
`;
return { ok: true };
}),
delete: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const message = await fetchMessageById(ctx.db, input.messageId);
if (!message) {
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
}
if (message.payload.src.generalId !== general.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
}
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
await invalidateMessages(ctx.db, ids);
return { ok: true, deletedIds: ids };
}),
getOld: authedProcedure getOld: authedProcedure
.input( .input(
z.object({ z.object({
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { appRouter } from '../src/router.js';
import type { GameApiContext, GeneralRow } from '../src/context.js';
const general = {
id: 7,
userId: 'user-7',
name: '보낸이',
nationId: 1,
officerLevel: 5,
npcState: 0,
meta: {},
penalty: {},
} as GeneralRow;
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2027-01-01T00:00:00.000Z',
sessionId: 'session-7',
user: {
id: 'user-7',
username: 'tester',
displayName: 'Tester',
roles: ['user'],
},
sanctions: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
general: {
findUnique: vi.fn(async () => general),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => null),
},
messageReadState: {
findUnique: vi.fn(async () => ({
generalId: general.id,
latestPrivateMessage: 11,
latestDiplomacyMessage: 13,
updatedAt: new Date(),
})),
},
message: { updateMany },
$queryRaw: vi.fn(async () => []),
$executeRaw: executeRaw,
...overrides,
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
} as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
};
describe('messages router missing-flow compatibility', () => {
it('returns persisted latest-read positions with recent messages', async () => {
const { caller } = buildContext();
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 });
});
it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext();
await caller.messages.readLatest({
generalId: general.id,
type: 'private',
messageId: 17,
});
expect(executeRaw).toHaveBeenCalledOnce();
});
it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [21, 22] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [
{
id: 23,
mailbox: general.id,
type: 'private',
src: 99,
dest: general.id,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: { generalId: 99, generalName: '타인', nationId: 1, nationName: '위', color: '#fff', icon: '' },
dest: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
text: '타인 메시지',
option: {},
},
},
]);
const { caller } = buildContext({ $queryRaw: queryRaw });
await expect(caller.messages.delete({ generalId: general.id, messageId: 23 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
@@ -492,6 +492,7 @@ export const createDatabaseTurnHooks = async (
name: nation.name, name: nation.name,
color: nation.color, color: nation.color,
capitalCityId: nation.capitalCityId, capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold, gold: nation.gold,
rice: nation.rice, rice: nation.rice,
level: nation.level, level: nation.level,
+1 -1
View File
@@ -257,7 +257,7 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({
name: row.name, name: row.name,
color: row.color, color: row.color,
capitalCityId: row.capitalCityId, capitalCityId: row.capitalCityId,
chiefGeneralId: null, chiefGeneralId: row.chiefGeneralId,
gold: row.gold, gold: row.gold,
rice: row.rice, rice: row.rice,
power: 0, power: 0,
+2
View File
@@ -27,6 +27,7 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*", "@sammo-ts/game-engine": "workspace:*",
@@ -38,6 +39,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"pm2": "^5.4.3", "pm2": "^5.4.3",
"redis": "^5.10.0", "redis": "^5.10.0",
"sharp": "^0.34.4",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import sharp from 'sharp';
import { z } from 'zod';
import type { GatewayApiContext } from '../context.js';
import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
const zSessionToken = z.string().min(1);
const zPassword = z.string().min(6).max(128);
const MAX_ICON_BYTES = 50 * 1024;
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
const session = await ctx.sessions.getSession(sessionToken);
if (!session) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' });
}
const user = await ctx.users.findById(session.userId);
if (!user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' });
}
return user;
};
const decodeImage = (input: string): Buffer => {
const match = input.match(/^data:[^;]+;base64,(.+)$/);
const encoded = match?.[1] ?? input;
const buffer = Buffer.from(encoded, 'base64');
if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 50KB 이하여야 합니다.' });
}
return buffer;
};
const sameUtcDate = (left: Date, right: Date): boolean =>
left.getUTCFullYear() === right.getUTCFullYear() &&
left.getUTCMonth() === right.getUTCMonth() &&
left.getUTCDate() === right.getUTCDate();
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
}
};
const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
for (const value of dates) {
if (value && new Date(value) > now) return true;
}
return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) =>
Boolean(restriction.until && new Date(restriction.until) > now)
);
};
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)}`;
};
export const accountRouter = router({
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles: user.roles,
oauthType: user.oauthType,
createdAt: user.createdAt,
iconUrl: buildIconUrl(ctx, user),
thirdPartyUse: user.thirdPartyUse,
deleteAfter: user.deleteAfter ?? null,
};
}),
changePassword: procedure
.input(
z.object({
sessionToken: zSessionToken,
currentPassword: zPassword,
newPassword: zPassword,
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
await ctx.users.updatePassword(user.id, input.newPassword);
await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed');
return { ok: true };
}),
scheduleDeletion: procedure
.input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
if (user.deleteAfter) {
throw new TRPCError({ code: 'CONFLICT', message: '이미 탈퇴 처리되어 있습니다.' });
}
const now = new Date();
if (hasActiveSanction(user.sanctions, now)) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '징계가 남아 있어 탈퇴할 수 없습니다.' });
}
const deleteAfter = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
await ctx.users.scheduleDeletion(user.id, deleteAfter);
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-scheduled');
return { ok: true, deleteAfter: deleteAfter.toISOString() };
}),
disallowThirdPartyUse: procedure
.input(z.object({ sessionToken: zSessionToken }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
await ctx.users.setThirdPartyUse(user.id, false);
return { ok: true };
}),
changeIcon: procedure
.input(
z.object({
sessionToken: zSessionToken,
imageData: z.string().min(1).max(100_000),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
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);
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
};
}),
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 };
}),
});
@@ -43,6 +43,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
oauthId: input.oauth?.id, oauthId: input.oauth?.id,
email: input.oauth?.email, email: input.oauth?.email,
oauthInfo: input.oauth?.info, oauthInfo: input.oauth?.info,
picture: 'default.jpg',
imageServer: 0,
thirdPartyUse: true,
passwordSalt: salt, passwordSalt: salt,
passwordHash: hasher.hash(input.password, salt), passwordHash: hasher.hash(input.password, salt),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
} }
throw new Error('User not found.'); throw new Error('User not found.');
}, },
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.picture = picture;
user.imageServer = imageServer;
user.iconUpdatedAt = updatedAt.toISOString();
return;
}
}
throw new Error('User not found.');
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.thirdPartyUse = allowed;
return;
}
}
throw new Error('User not found.');
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.deleteAfter = deleteAfter.toISOString();
return;
}
}
throw new Error('User not found.');
},
async deleteUser(userId: string): Promise<void> { async deleteUser(userId: string): Promise<void> {
for (const [username, user] of usersByName.entries()) { for (const [username, user] of usersByName.entries()) {
if (user.id === userId) { if (user.id === userId) {
@@ -29,6 +29,11 @@ const mapUser = (row: {
oauthId: string | null; oauthId: string | null;
email: string | null; email: string | null;
oauthInfo: GatewayPrisma.JsonValue; oauthInfo: GatewayPrisma.JsonValue;
picture: string;
imageServer: number;
iconUpdatedAt: Date | null;
thirdPartyUse: boolean;
deleteAfter: Date | null;
createdAt: Date; createdAt: Date;
}): UserRecord => ({ }): UserRecord => ({
id: row.id, id: row.id,
@@ -40,6 +45,11 @@ const mapUser = (row: {
oauthId: row.oauthId ?? undefined, oauthId: row.oauthId ?? undefined,
email: row.email ?? undefined, email: row.email ?? undefined,
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}), oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
picture: row.picture,
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash, passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt, passwordSalt: row.passwordSalt,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@@ -139,6 +149,28 @@ export const createPostgresUserRepository = (
}, },
}); });
}, },
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: {
picture,
imageServer,
iconUpdatedAt: updatedAt,
},
});
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { thirdPartyUse: allowed },
});
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { deleteAfter },
});
},
async deleteUser(userId: string): Promise<void> { async deleteUser(userId: string): Promise<void> {
await prisma.appUser.delete({ await prisma.appUser.delete({
where: { id: userId }, where: { id: userId },
@@ -8,6 +8,11 @@ export interface UserRecord {
oauthId?: string; oauthId?: string;
email?: string; email?: string;
oauthInfo?: UserOAuthInfo; oauthInfo?: UserOAuthInfo;
picture: string;
imageServer: number;
iconUpdatedAt?: string;
thirdPartyUse: boolean;
deleteAfter?: string;
passwordHash: string; passwordHash: string;
passwordSalt: string; passwordSalt: string;
createdAt: string; createdAt: string;
@@ -18,6 +23,7 @@ export interface PublicUser {
username: string; username: string;
displayName: string; displayName: string;
roles: string[]; roles: string[];
picture: string;
createdAt: string; createdAt: string;
} }
@@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
roles: user.roles, roles: user.roles,
picture: user.picture,
createdAt: user.createdAt, createdAt: user.createdAt,
}); });
@@ -70,6 +77,9 @@ export interface UserRepository {
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>; updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
updateRoles(userId: string, roles: string[]): Promise<void>; updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>; updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
deleteUser(userId: string): Promise<void>; deleteUser(userId: string): Promise<void>;
} }
+4
View File
@@ -16,6 +16,8 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string; kakaoAdminKey?: string;
kakaoRedirectUri: string; kakaoRedirectUri: string;
publicBaseUrl: string; publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean; adminLocalAccountEnabled: boolean;
orchestratorEnabled: boolean; orchestratorEnabled: boolean;
orchestratorReconcileIntervalMs: number; orchestratorReconcileIntervalMs: number;
@@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri, kakaoRedirectUri,
publicBaseUrl, publicBaseUrl,
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false), adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false), orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumberWithFallback( orchestratorReconcileIntervalMs: parseNumberWithFallback(
+6
View File
@@ -18,6 +18,8 @@ export interface GatewayApiContext {
kakaoClient: KakaoOAuthClient; kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore; oauthSessions: OAuthSessionStore;
publicBaseUrl: string; publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean; adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository; profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle; orchestrator: GatewayOrchestratorHandle;
@@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: KakaoOAuthClient; kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore; oauthSessions: OAuthSessionStore;
publicBaseUrl: string; publicBaseUrl: string;
userIconDir?: string;
userIconPublicUrl?: string;
adminLocalAccountEnabled: boolean; adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository; profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle; orchestrator: GatewayOrchestratorHandle;
@@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: options.kakaoClient, kakaoClient: options.kakaoClient,
oauthSessions: options.oauthSessions, oauthSessions: options.oauthSessions,
publicBaseUrl: options.publicBaseUrl, publicBaseUrl: options.publicBaseUrl,
userIconDir: options.userIconDir ?? 'uploads/user-icons',
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: options.adminLocalAccountEnabled, adminLocalAccountEnabled: options.adminLocalAccountEnabled,
profiles: options.profiles, profiles: options.profiles,
orchestrator: options.orchestrator, orchestrator: options.orchestrator,
+8
View File
@@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js'; import type { UserOAuthInfo } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js'; import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
const zUsername = z.string().min(2).max(32); const zUsername = z.string().min(2).max(32);
const zPassword = z.string().min(6).max(128); const zPassword = z.string().min(6).max(128);
@@ -61,6 +62,7 @@ export const appRouter = router({
}), }),
}), }),
admin: adminRouter, admin: adminRouter,
account: accountRouter,
auth: router({ auth: router({
bootstrapLocal: procedure bootstrapLocal: procedure
.input( .input(
@@ -330,6 +332,12 @@ export const appRouter = router({
message: 'Invalid username or password.', message: 'Invalid username or password.',
}); });
} }
if (user.deleteAfter) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account deletion is pending.',
});
}
const ok = await ctx.users.verifyPassword(user, input.password); const ok = await ctx.users.verifyPassword(user, input.password);
if (!ok) { if (!ok) {
throw new TRPCError({ throw new TRPCError({
+11
View File
@@ -1,5 +1,8 @@
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { import {
createGatewayPostgresConnector, createGatewayPostgresConnector,
@@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => {
origin: true, origin: true,
credentials: true, credentials: true,
}); });
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
await app.register(fastifyStatic, {
root: path.resolve(process.cwd(), config.userIconDir),
prefix: '/user-icons/',
decorateReply: false,
});
await app.register(fastifyTRPCPlugin, { await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath, prefix: config.trpcPath,
@@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => {
kakaoClient, kakaoClient,
oauthSessions, oauthSessions,
publicBaseUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl,
userIconDir: path.resolve(process.cwd(), config.userIconDir),
userIconPublicUrl: config.userIconPublicUrl,
adminLocalAccountEnabled: config.adminLocalAccountEnabled, adminLocalAccountEnabled: config.adminLocalAccountEnabled,
profiles, profiles,
orchestrator, orchestrator,
+104 -2
View File
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
@@ -10,7 +14,7 @@ import { appRouter } from '../src/router.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra'; import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
const buildCaller = () => { const buildCaller = (options: { userIconDir?: string } = {}) => {
const users = createInMemoryUserRepository(); const users = createInMemoryUserRepository();
const sessions = new InMemoryGatewaySessionService({ const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 3600, sessionTtlSeconds: 3600,
@@ -83,6 +87,8 @@ const buildCaller = () => {
kakaoClient: kakaoClient as unknown as KakaoOAuthClient, kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
oauthSessions, oauthSessions,
publicBaseUrl: 'http://localhost', publicBaseUrl: 'http://localhost',
userIconDir: options.userIconDir,
userIconPublicUrl: 'http://localhost/user-icons',
adminLocalAccountEnabled: false, adminLocalAccountEnabled: false,
profiles, profiles,
orchestrator, orchestrator,
@@ -95,7 +101,7 @@ const buildCaller = () => {
} as unknown as GatewayPrismaClient, } as unknown as GatewayPrismaClient,
}) })
); );
return { caller, oauthSessions }; return { caller, oauthSessions, users, sessions };
}; };
describe('gateway auth flow', () => { describe('gateway auth flow', () => {
@@ -167,3 +173,99 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester'); expect(validated?.user.username).toBe('tester');
}); });
}); });
describe('account self service', () => {
it('changes only the authenticated user password after verifying the current password', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'self-service',
password: 'current-password',
});
const session = await sessions.createSession(user);
await expect(
caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'wrong-password',
newPassword: 'next-password',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
newPassword: 'next-password',
});
const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
});
it('revokes the session and schedules deletion after 30 days', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'delete-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const result = await caller.account.scheduleDeletion({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
});
expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
expect((await users.findById(user.id))?.deleteAfter).toBe(result.deleteAfter);
expect(await sessions.getSession(session.sessionToken)).toBeNull();
});
it('revokes third-party use consent without allowing it to be re-enabled', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'privacy-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
await caller.account.disallowThirdPartyUse({ sessionToken: session.sessionToken });
expect((await users.findById(user.id))?.thirdPartyUse).toBe(false);
});
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 user = await users.createUser({
username: 'icon-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#334455',
},
})
.png()
.toBuffer();
const result = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(updated?.imageServer).toBe(1);
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',
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
});
+23
View File
@@ -95,6 +95,7 @@ model Nation {
name String name String
color String color String
capitalCityId Int? @map("capital_city_id") capitalCityId Int? @map("capital_city_id")
chiefGeneralId Int? @map("chief_general_id")
gold Int @default(0) gold Int @default(0)
rice Int @default(0) rice Int @default(0)
tech Float @default(0) tech Float @default(0)
@@ -193,6 +194,28 @@ model GeneralAccessLog {
@@map("general_access_log") @@map("general_access_log")
} }
model MessageReadState {
generalId Int @id @map("general_id")
latestPrivateMessage Int @default(0) @map("latest_private_message")
latestDiplomacyMessage Int @default(0) @map("latest_diplomacy_message")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("message_read_state")
}
model Message {
id Int @id @default(autoincrement())
mailbox Int
type String
src Int
dest Int
time DateTime
validUntil DateTime @map("valid_until")
message Json
@@map("message")
}
model RankData { model RankData {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id") nationId Int @default(0) @map("nation_id")
@@ -0,0 +1,6 @@
ALTER TABLE "app_user"
ADD COLUMN "picture" TEXT NOT NULL DEFAULT 'default.jpg',
ADD COLUMN "image_server" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "icon_updated_at" TIMESTAMP(3),
ADD COLUMN "third_party_use" BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN "delete_after" TIMESTAMP(3);
+5
View File
@@ -43,6 +43,11 @@ model AppUser {
oauthId String? @unique @map("oauth_id") oauthId String? @unique @map("oauth_id")
email String? @unique email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at") lastLoginAt DateTime? @map("last_login_at")
@@ -0,0 +1,6 @@
CREATE TABLE "message_read_state" (
"general_id" INTEGER PRIMARY KEY,
"latest_private_message" INTEGER NOT NULL DEFAULT 0,
"latest_diplomacy_message" INTEGER NOT NULL DEFAULT 0,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,2 @@
ALTER TABLE "nation"
ADD COLUMN "chief_general_id" INTEGER;
+2
View File
@@ -7,6 +7,8 @@ export interface DatabaseClient {
worldState: GamePrisma.WorldStateDelegate; worldState: GamePrisma.WorldStateDelegate;
general: GamePrisma.GeneralDelegate; general: GamePrisma.GeneralDelegate;
generalAccessLog: GamePrisma.GeneralAccessLogDelegate; generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate;
city: GamePrisma.CityDelegate; city: GamePrisma.CityDelegate;
nation: GamePrisma.NationDelegate; nation: GamePrisma.NationDelegate;
diplomacy: GamePrisma.DiplomacyDelegate; diplomacy: GamePrisma.DiplomacyDelegate;
+2
View File
@@ -84,6 +84,7 @@ export interface TurnEngineNationRow {
name: string; name: string;
color: string; color: string;
capitalCityId: number | null; capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number; gold: number;
rice: number; rice: number;
tech: number; tech: number;
@@ -287,6 +288,7 @@ export interface TurnEngineNationCreateManyInput {
name: string; name: string;
color: string; color: string;
capitalCityId: number | null; capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number; gold: number;
rice: number; rice: number;
tech: number; tech: number;
+6
View File
@@ -233,6 +233,9 @@ importers:
'@fastify/cors': '@fastify/cors':
specifier: ^11.2.0 specifier: ^11.2.0
version: 11.2.0 version: 11.2.0
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
'@prisma/client': '@prisma/client':
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2)
@@ -266,6 +269,9 @@ importers:
redis: redis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
sharp:
specifier: ^0.34.4
version: 0.34.5
zod: zod:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
@@ -295,7 +295,7 @@ describe('auction integration flow', () => {
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({ const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: adminSessionRef.value ?? '', sessionToken: adminSessionRef.value ?? '',
profile: 'che:2', profile: 'che:908',
}); });
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({ const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
gatewayToken: adminGatewayToken.gameToken, gatewayToken: adminGatewayToken.gameToken,