From fc7de05017d7865277efcd8b88a40a2e6e4969f8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:55:32 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=EA=B3=BC=20=EA=B3=84=EC=A0=95=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EA=B8=B0=EB=B0=98=EC=9D=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다. --- .../src/auth/inMemoryUserRepository.ts | 3 + .../src/auth/localAccountPolicy.ts | 17 +- app/gateway-api/src/auth/oauthSessionStore.ts | 15 +- .../src/auth/postgresUserRepository.ts | 6 + app/gateway-api/src/auth/userRepository.ts | 3 +- app/gateway-api/src/router.ts | 105 +++++- app/gateway-api/test/authFlow.test.ts | 116 ++++++- .../test/localAccountPolicy.test.ts | 20 ++ .../test/oauthSessionStore.test.ts | 6 +- .../test/passwordCredential.test.ts | 4 + app/gateway-api/test/releaseManifest.test.ts | 10 +- .../e2e/kakao-account-recovery.spec.ts | 62 ++++ .../src/views/OAuthCallbackView.vue | 94 ++++- docs/legacy-db-migration.md | 117 +++++-- packages/common/src/index.ts | 1 + .../legacyArchive/ArchivedGeneralSnapshot.ts | 321 ++++++++++++++++++ .../migration.sql | 6 + packages/infra/prisma/gateway.prisma | 1 + .../migration.sql | 125 +++++++ pnpm-lock.yaml | 3 + release-manifest.json | 4 +- tools/legacy-db-migration/README.md | 28 +- tools/legacy-db-migration/package.json | 1 + tools/legacy-db-migration/src/cli.ts | 16 +- tools/legacy-db-migration/src/db.ts | 16 +- tools/legacy-db-migration/src/game.ts | 222 ++++++++---- tools/legacy-db-migration/src/gateway.ts | 108 +++++- .../test/archiveGeneral.test.ts | 71 ++++ tools/legacy-db-migration/test/db.test.ts | 24 ++ .../che-old-general-legacy-flat-v0.json | 50 +++ .../fixtures/hwe-old-general-ref-flat-v1.json | 36 ++ tools/legacy-db-migration/test/game.test.ts | 146 ++++++++ .../legacy-db-migration/test/gateway.test.ts | 113 ++++++ 33 files changed, 1720 insertions(+), 150 deletions(-) create mode 100644 packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts create mode 100644 packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql create mode 100644 packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql create mode 100644 tools/legacy-db-migration/test/archiveGeneral.test.ts create mode 100644 tools/legacy-db-migration/test/db.test.ts create mode 100644 tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json create mode 100644 tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json create mode 100644 tools/legacy-db-migration/test/game.test.ts create mode 100644 tools/legacy-db-migration/test/gateway.test.ts diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 87adb3ea..ab4779fd 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass kakaoGraceStartedAt: now.toISOString(), passwordSalt: password.salt, passwordHash: password.hash, + passwordResetRequired: false, createdAt: now.toISOString(), }; usersByName.set(input.username, user); @@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const upgraded = await hasher.hash(password); user.passwordSalt = upgraded.salt; user.passwordHash = upgraded.hash; + user.passwordResetRequired = false; } return verified.ok; }, @@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const next = await hasher.hash(password); user.passwordSalt = next.salt; user.passwordHash = next.hash; + user.passwordResetRequired = false; return; } } diff --git a/app/gateway-api/src/auth/localAccountPolicy.ts b/app/gateway-api/src/auth/localAccountPolicy.ts index d1f81476..7d26d42e 100644 --- a/app/gateway-api/src/auth/localAccountPolicy.ts +++ b/app/gateway-api/src/auth/localAccountPolicy.ts @@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = ( grants: readonly SpecialAccountAccessGrantRecord[], now: Date = new Date() ): boolean => - grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())); + grants.some( + (grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()) + ); const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean => grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName); @@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: { const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!; const expiresAt = active.some((grant) => !grant.expiresAt) ? null - : active - .map((grant) => grant.expiresAt!) - .sort((left, right) => right.localeCompare(left))[0] ?? null; + : (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null); return { kind: selected.kind, grantId: selected.id, @@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: { 'localAccountGeneralCreationGraceDays', generalCreationDefault ); - const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt); + const kakaoVerified = + options.user.oauthType === 'KAKAO' && + Boolean(options.user.oauthId?.trim()) && + Boolean(options.user.kakaoVerifiedAt); const graceStartedAt = new Date(options.user.kakaoGraceStartedAt); const now = options.now ?? new Date(); const specialAccess = resolveSpecialAccess({ @@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: { const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS); const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt; const canCreateGeneral = - kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt); + kakaoVerified || + specialAccess?.allowsGeneralCreation === true || + (accessAllowed && now < generalCreationEndsAt); return { requiresKakaoVerification: !kakaoVerified && specialAccess === null, diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 3a3c3bfb..8dbe6a74 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -14,7 +14,7 @@ export interface OAuthPendingState { export interface OAuthSession { id: string; mode: OAuthMode; - intent?: 'register' | 'link_existing' | 'rejoin'; + intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup'; targetUserId?: string; kakaoId: string; email: string; @@ -93,6 +93,14 @@ end return cjson.encode({ status = 'verified', userId = challenge.userId }) `; +const consumeOnceScript = ` +local raw = redis.call('GET', KEYS[1]) +if raw then + redis.call('DEL', KEYS[1]) +end +return raw +`; + export class RedisOAuthSessionStore implements OAuthSessionStore { private readonly client: RedisClientLike; private readonly prefix: string; @@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { async consumeSession(sessionId: string): Promise { const key = this.sessionKey(sessionId); - const raw = await this.client.get(key); + const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] }); if (!raw) { return null; } - await this.client.del(key); - return parseJson(raw); + return typeof raw === 'string' ? parseJson(raw) : null; } async getLoginChallengeForUser(userId: string): Promise { diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 606b9741..d9c6a123 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -59,6 +59,7 @@ const mapUser = (row: { displayName: string; passwordHash: string; passwordSalt: string; + passwordResetRequired: boolean; roles: GatewayPrisma.JsonValue; sanctions: GatewayPrisma.JsonValue; oauthType: 'NONE' | 'KAKAO'; @@ -107,6 +108,7 @@ const mapUser = (row: { deleteAfter: row.deleteAfter?.toISOString(), passwordHash: row.passwordHash, passwordSalt: row.passwordSalt, + passwordResetRequired: row.passwordResetRequired, createdAt: row.createdAt.toISOString(), legacyMemberNo: readLegacyMemberNo(row.legacyData), legacyGrade: readLegacyGrade(row.legacyData), @@ -250,6 +252,7 @@ export const createPostgresUserRepository = ( displayName: input.displayName ?? input.username, passwordHash: password.hash, passwordSalt: password.salt, + passwordResetRequired: false, roles: ['user'] satisfies GatewayPrisma.JsonArray, sanctions: {} satisfies GatewayPrisma.JsonObject, oauthType, @@ -274,10 +277,12 @@ export const createPostgresUserRepository = ( data: { passwordHash: upgraded.hash, passwordSalt: upgraded.salt, + passwordResetRequired: false, }, }); user.passwordHash = upgraded.hash; user.passwordSalt = upgraded.salt; + user.passwordResetRequired = false; } return verified.ok; }, @@ -288,6 +293,7 @@ export const createPostgresUserRepository = ( data: { passwordHash: next.hash, passwordSalt: next.salt, + passwordResetRequired: false, }, }); }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index a8076005..796b81c9 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -24,6 +24,7 @@ export interface UserRecord { deleteAfter?: string; passwordHash: string; passwordSalt: string; + passwordResetRequired: boolean; createdAt: string; legacyMemberNo?: number; legacyGrade?: number; @@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({ displayName: user.displayName, roles: user.roles, picture: user.picture, - kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), + kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt), kakaoGraceStartedAt: user.kakaoGraceStartedAt, createdAt: user.createdAt, }); diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 80acea61..9d72138f 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -91,6 +91,42 @@ const finishKakaoLogin = async ( }; }; +const finishKakaoLoginOrRequestPasswordSetup = async ( + ctx: GatewayApiContext, + user: UserRecord, + accessToken: string, + successStatus: T +) => { + if (!user.passwordResetRequired) { + return finishKakaoLogin(ctx, user, accessToken, successStatus); + } + if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.', + }); + } + const oauthInfo = user.oauthInfo ?? {}; + const passwordSetup = await ctx.oauthSessions.createSession({ + mode: successStatus === 'verified' ? 'verify' : 'login', + intent: 'password_setup', + targetUserId: user.id, + kakaoId: user.oauthId, + email: user.email, + accessToken, + refreshToken: oauthInfo.refreshToken, + accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(), + refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil, + createdAt: new Date().toISOString(), + }); + return { + status: 'password_setup' as const, + oauthSessionId: passwordSetup.id, + email: passwordSetup.email, + successStatus, + }; +}; + export const appRouter = router({ health: router({ ping: procedure.query(() => ({ @@ -348,7 +384,7 @@ export const appRouter = router({ } const refreshed = (await ctx.users.findById(verified.id)) ?? verified; await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified'); - return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified'); } if (pending.mode === 'change_pw') { @@ -415,7 +451,7 @@ export const appRouter = router({ cause: error, }); } - return finishKakaoLogin(ctx, synced, token.accessToken, 'login'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login'); } const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); @@ -562,7 +598,70 @@ export const appRouter = router({ }); } await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); - return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login'); + }), + kakaoSetPassword: procedure + .input( + z.object({ + oauthSessionId: z.string().uuid(), + credential: zPasswordEnvelope, + }) + ) + .mutation(async ({ ctx, input }) => { + const password = openPassword(ctx.passwordEnvelope, input.credential); + const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); + if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.', + }); + } + const user = await ctx.users.findById(oauthSession.targetUserId); + if ( + !user || + user.oauthType !== 'KAKAO' || + user.oauthId !== oauthSession.kakaoId || + user.email?.toLowerCase() !== oauthSession.email.toLowerCase() || + !user.passwordResetRequired + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', + }); + } + if (user.deleteAfter) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' }); + } + if (isLoginBanned(user.sanctions)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' }); + } + let verifiedProfile; + try { + verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken)); + } catch (error) { + return throwKakaoVerificationError(error); + } + if ( + verifiedProfile.kakaoId !== oauthSession.kakaoId || + verifiedProfile.email !== oauthSession.email.toLowerCase() + ) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.', + }); + } + await ctx.users.updatePassword(user.id, password); + const refreshed = await ctx.users.findById(user.id); + if (!refreshed) { + throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' }); + } + await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed'); + return finishKakaoLogin( + ctx, + refreshed, + oauthSession.accessToken, + oauthSession.mode === 'verify' ? 'verified' : 'login' + ); }), register: procedure .input( diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index f10c0dba..660f15de 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -631,7 +631,7 @@ describe('gateway auth flow', () => { }); it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => { - const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller(); + const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller(); const emailOwner = await users.createUser({ username: 'email-owner', password: 'owner-password', @@ -642,6 +642,7 @@ describe('gateway auth flow', () => { info: {}, }, }); + emailOwner.passwordResetRequired = true; await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); kakaoProfile.id = 'different-kakao-id'; @@ -659,7 +660,14 @@ describe('gateway auth flow', () => { oauthSessionId: recovery.oauthSessionId, action: 'link_existing', }); - expect(linked.status).toBe('otp'); + expect(linked.status).toBe('password_setup'); + if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + expect(sentTalkMessages).toHaveLength(0); + const passwordSet = await caller.auth.kakaoSetPassword({ + oauthSessionId: linked.oauthSessionId, + credential: sealPassword('replacement-password'), + }); + expect(passwordSet.status).toBe('otp'); expect(sentTalkMessages).toHaveLength(1); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ @@ -668,6 +676,108 @@ describe('gateway auth flow', () => { email: 'tester@example.com', }); expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); + expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed'); + }); + + it('requires a one-time password setup before an imported Kakao account can receive a session', async () => { + const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({ + kakaoId: 'imported-kakao-id', + kakaoEmail: 'imported@example.com', + }); + const user = await users.createUser({ + username: 'imported-kakao-user', + password: 'legacy-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: kakaoProfile.email, + info: {}, + }, + }); + user.passwordResetRequired = true; + const createSession = vi.spyOn(sessions, 'createSession'); + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + expect(login).toMatchObject({ + status: 'password_setup', + email: 'imported@example.com', + successStatus: 'login', + }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + expect(login).not.toHaveProperty('sessionToken'); + expect(createSession).not.toHaveBeenCalled(); + expect(sentTalkMessages).toHaveLength(0); + + const setup = await caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-imported-password'), + }); + expect(setup.status).toBe('otp'); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(false); + expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true); + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('another-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('rechecks sanctions before consuming a migrated password setup', async () => { + const { caller, users, kakaoProfile, sealPassword } = buildCaller({ + kakaoId: 'sanctioned-setup-id', + kakaoEmail: 'sanctioned-setup@example.com', + }); + const user = await users.createUser({ + username: 'sanctioned-setup-user', + password: 'legacy-password', + oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} }, + }); + user.passwordResetRequired = true; + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' }); + + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('blocked-password'), + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(true); + }); + + it('consumes password setup when the provider identity changes before submission', async () => { + const { caller, users, kakaoProfile, sealPassword } = buildCaller({ + kakaoId: 'setup-target-id', + kakaoEmail: 'setup-target@example.com', + }); + const user = await users.createUser({ + username: 'setup-target-user', + password: 'legacy-password', + oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} }, + }); + user.passwordResetRequired = true; + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + kakaoProfile.id = 'changed-provider-id'; + + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-target-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(true); + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-target-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => { @@ -1147,6 +1257,7 @@ describe('account self service', () => { username: 'self-service', password: 'current-password', }); + user.passwordResetRequired = true; const session = await sessions.createSession(user); await expect( @@ -1165,6 +1276,7 @@ describe('account self service', () => { const refreshed = await users.findById(user.id); expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true); + expect(refreshed?.passwordResetRequired).toBe(false); }); it('revokes the session and schedules deletion after 30 days', async () => { diff --git a/app/gateway-api/test/localAccountPolicy.test.ts b/app/gateway-api/test/localAccountPolicy.test.ts index 261c6dc0..976a5349 100644 --- a/app/gateway-api/test/localAccountPolicy.test.ts +++ b/app/gateway-api/test/localAccountPolicy.test.ts @@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({ kakaoGraceStartedAt: graceStartedAt.toISOString(), passwordHash: 'unused', passwordSalt: '', + passwordResetRequired: false, createdAt: graceStartedAt.toISOString(), }); @@ -90,6 +91,25 @@ describe('local account profile policy', () => { }); }); + it('does not trust a migrated Kakao marker without a valid provider ID', () => { + const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z')); + user.oauthType = 'KAKAO'; + user.oauthId = ' '; + user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z'; + const policy = resolveLocalAccountProfilePolicy({ + profile: 'che', + defaultGraceDays: 0, + user, + now: new Date('2026-07-26T00:00:00.000Z'), + }); + + expect(policy).toMatchObject({ + kakaoVerified: false, + requiresKakaoVerification: true, + accessAllowed: false, + }); + }); + it('extends account access with an administrator override without widening general creation grace', () => { const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z')); user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z'; diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index 681935de..6dc7b89b 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { }); sessionIds.add(session.id); - await expect(store.consumeSession(session.id)).resolves.toMatchObject({ + await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0); + const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]); + expect(consumed.filter((value) => value !== null)).toHaveLength(1); + expect(consumed.find((value) => value !== null)).toMatchObject({ id: session.id, intent: 'link_existing', targetUserId, email: 'retained@example.test', }); - await expect(store.consumeSession(session.id)).resolves.toBeNull(); }); it('atomically consumes a successful code once', async () => { diff --git a/app/gateway-api/test/passwordCredential.test.ts b/app/gateway-api/test/passwordCredential.test.ts index 3d85f2f4..4d6c9945 100644 --- a/app/gateway-api/test/passwordCredential.test.ts +++ b/app/gateway-api/test/passwordCredential.test.ts @@ -34,10 +34,12 @@ describe('password credential compatibility', () => { }); user.passwordSalt = 'core-salt'; user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex'); + user.passwordResetRequired = true; expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordSalt).toBe(''); + expect(user.passwordResetRequired).toBe(false); }); it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => { @@ -52,10 +54,12 @@ describe('password credential compatibility', () => { const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex'); user.passwordSalt = userSalt; user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex'); + user.passwordResetRequired = true; expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordSalt).toBe(''); + expect(user.passwordResetRequired).toBe(false); }); it('does not accept an imported ref credential without the matching global salt', async () => { diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index b707a5d7..df4b3012 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,8 +38,8 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', - gameSchemaHead: '20260816000000_add_read_model_change_journal', + gatewaySchemaHead: '20260817000000_add_password_reset_required', + gameSchemaHead: '20260817001000_add_dedicated_legacy_archive', }); }); @@ -61,11 +61,7 @@ describe('readReleaseManifest', () => { it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => { const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1; - const workspace = await createWorkspace( - '20260801000000_gateway', - '20260801000000_game', - futureProtocol - ); + const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol); await expect(readReleaseManifest(workspace)).rejects.toThrow( `Release requires controller protocol ${futureProtocol}` diff --git a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts index e2b8ffb2..0c0325c6 100644 --- a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts +++ b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts @@ -1,3 +1,5 @@ +import { generateKeyPairSync } from 'node:crypto'; + import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); @@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; +const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + +const installPasswordSetupFixture = async (page: Page) => { + const calls: string[] = []; + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + calls.push(operation); + if (operation === 'me') return response(null); + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') return response([]); + if (operation === 'auth.passwordKey') { + return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' }); + } + if (operation === 'auth.kakaoExchange') { + return response({ + status: 'password_setup', + oauthSessionId: '11111111-1111-4111-8111-111111111112', + email: 'migrated@example.test', + successStatus: 'login', + }); + } + if (operation === 'auth.kakaoSetPassword') { + return response({ + status: 'otp', + challengeId: '11111111-1111-4111-8111-111111111111', + expiresAt: '2026-08-17T12:03:00.000Z', + attemptsRemaining: 3, + }); + } + throw new Error(`Unhandled password setup fixture operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); + return calls; +}; + const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => { const calls: string[] = []; await page.route('**/gateway/api/trpc/**', async (route) => { @@ -108,4 +147,27 @@ for (const viewport of [ expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1); expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); }); + + test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => { + const calls = await installPasswordSetupFixture(page); + await page.setViewportSize(viewport); + await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state'); + + const form = page.getByRole('form', { name: '새 비밀번호 설정' }); + await expect(form).toBeVisible(); + await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.'); + await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test'); + const geometry = await form.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, right: rect.right }; + }); + await form.getByLabel('새 비밀번호').fill('new-password-value'); + await form.getByLabel('비밀번호 확인').fill('new-password-value'); + await form.getByRole('button', { name: '새 비밀번호 설정' }).click(); + + await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible(); + expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1); + expect(geometry.width).toBeGreaterThan(300); + expect(geometry.right).toBeLessThanOrEqual(viewport.width); + }); } diff --git a/app/gateway-frontend/src/views/OAuthCallbackView.vue b/app/gateway-frontend/src/views/OAuthCallbackView.vue index e925c558..6e674667 100644 --- a/app/gateway-frontend/src/views/OAuthCallbackView.vue +++ b/app/gateway-frontend/src/views/OAuthCallbackView.vue @@ -14,6 +14,8 @@ const submitting = ref(false); const errorMessage = ref(''); const infoMessage = ref(''); const oauthSessionId = ref(''); +const passwordSetupSessionId = ref(''); +const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login'); const email = ref(''); const username = ref(''); const password = ref(''); @@ -60,6 +62,12 @@ const completeExchange = async (): Promise => { infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.'; return; } + if (result.status === 'password_setup') { + passwordSetupSessionId.value = result.oauthSessionId; + passwordSetupSuccessStatus.value = result.successStatus; + email.value = result.email; + return; + } if (result.status === 'account_recovery') { accountRecovery.value = result; email.value = result.email; @@ -94,6 +102,12 @@ const resolveAccount = async (): Promise => { await router.replace('/lobby'); return; } + if (result.status === 'password_setup') { + passwordSetupSessionId.value = result.oauthSessionId; + passwordSetupSuccessStatus.value = result.successStatus; + email.value = result.email; + return; + } oauthSessionId.value = result.oauthSessionId; email.value = result.email; } catch (error) { @@ -103,6 +117,36 @@ const resolveAccount = async (): Promise => { } }; +const setMigratedPassword = async (): Promise => { + errorMessage.value = ''; + if (password.value !== confirmPassword.value) { + errorMessage.value = '비밀번호 확인이 일치하지 않습니다.'; + return; + } + submitting.value = true; + try { + const credential = await sealPassword(password.value); + const result = await trpc.auth.kakaoSetPassword.mutate({ + oauthSessionId: passwordSetupSessionId.value, + credential, + }); + password.value = ''; + confirmPassword.value = ''; + passwordSetupSessionId.value = ''; + if (result.status === 'otp') { + otpChallenge.value = result; + otpSuccessStatus.value = passwordSetupSuccessStatus.value; + return; + } + window.localStorage.setItem('sammo-session-token', result.sessionToken); + await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby'); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.'; + } finally { + submitting.value = false; + } +}; + const register = async (): Promise => { errorMessage.value = ''; if (password.value !== confirmPassword.value) { @@ -156,7 +200,15 @@ onMounted(() => {

삼국지 모의전투 HiDCHe

-

{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}

+

+ {{ + accountRecovery + ? '카카오 계정 연결 확인' + : passwordSetupSessionId + ? '새 비밀번호 설정' + : '회원가입' + }} +

카카오 인증을 확인하는 중...

{{ infoMessage }}

@@ -181,6 +233,46 @@ onMounted(() => { 취소
+
+

+ 카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요. +

+
+ + +
+
+ + +
+
+ + +
+ + 취소 +
diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index 5c6509bf..3261972c 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -9,6 +9,13 @@ PostgreSQL advisory locks serialize an apply per profile, and every target row uses a stable legacy key with `ON CONFLICT`, so an interrupted run is repeatable. +Gateway apply is one PostgreSQL transaction. A game apply records a +`legacy_archive.import_run`: archive and current-user projection writes commit +together with `COMPLETED`, while a rollback leaves a `FAILED` run record. A +repeat import updates archive-owned rows but does not replace a live Gateway +account's password, reset status, login/display identity, OAuth connection, +roles, sanctions, consent, icon or login timestamps. + The source of truth for eligibility is the checked ref schema, not every table that happens to exist in a dump. Tables outside that schema remain only in the recovery dump. @@ -28,18 +35,26 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by that UUID, so references such as `ng_old_generals.owner` remain stable even when an old account was deleted before the dump. -Kakao members retain `oauth_id`, email and metadata. A parseable legacy +Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is +required before an imported row is marked Kakao-verified. A parseable legacy `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the remaining KakaoTalk ownership-proof interval instead of forcing an immediate -message at cutover. Cutover also sets `kakao_verified_at` and +message at cutover. Valid provider rows also receive `kakao_verified_at`; all +rows receive `kakao_grace_started_at` to the migration time and starts the local-account verification grace period there. Source rows without an OAuth ID retain their -metadata, but the importer does not invent a provider identifier. +metadata but are not treated as verified, and the importer never invents a +provider identifier. -Legacy password hashes remain usable when gateway-api has -`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored -value to Argon2id. A test-only account can instead be reset with the CLI and a -mode-0600 password file: +Every imported 128-hex legacy password is marked `password_reset_required`. +The dump contains the per-user salt but not Ref's installation-wide salt, so +the dump alone cannot validate the old plaintext password. If the original +`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is recovered through the runtime secret, +a successful password login upgrades the value to Argon2id and clears the +flag. Otherwise, a Kakao login (including a confirmed retained-email relink) +issues a one-time password-setup challenge before any normal session; the new +password is sent in the existing RSA envelope and clears the flag. Accounts +without usable Kakao recovery require the CLI and a mode-0600 password file: ```sh GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \ @@ -50,24 +65,43 @@ The password is never accepted as an argument or printed. ### Game profiles -| Legacy table | Target | Policy | -| ------------------------------- | ------------------------ | --------------------------------------------------------------------- | -| `ng_games` | `ng_games` | Preserve completed season metadata | -| `hall` | `hall` | Preserve hall-of-fame rows | -| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner | -| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs | -| `emperior` | `emperior` | Preserve dynasty detail and legacy key | -| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | -| `user_record` | `inheritance_log` | Preserve complete long-lived user record | -| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | -| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage | -| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage | -| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots | +| Legacy table | Dedicated target | Policy | +| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- | +| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment | +| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records | +| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner | +| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key | +| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID | +| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | +| `user_record` | `inheritance_log` | Preserve complete long-lived user record | +| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | +| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage | +| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage | +| `ng_history` | `legacy_archive.yearbook` | Preserve map, nation, global history and global action snapshots | + +The archive schema is shared by all game-profile schemas in the PostgreSQL +database. Every natural key contains `source_profile`; the accepted profiles +are `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, and `hwe`. This prevents equal +legacy IDs from different servers from colliding while allowing any profile API +to read one central archive. + +`ng_games.date` is retained as `legacy_date`. The displayed opening date uses +`env.opentime`, then `env.starttime`, then `ng_games.date`. The dumps do not +carry a trustworthy completion timestamp, so `completed_at` remains null +instead of treating the opening date as completion. + +`ng_old_generals.data` is adapted at import time to +`ArchivedGeneralSnapshotV1`. Both old `leader/power` with +`dex0/10/20/30/40` and newer `leadership/strength` with `dex1..5` map to one +shape. Missing battle aggregates and logs are `null` plus explicit +`availability`, never fabricated zeroes. The source JSON remains in +`legacy_archive.general.raw_data` for recovery, but no API returns it. The source contains legitimate duplicate `(server_id, nation)` old-nation rows and `(server_id, year, month)` history rows. `source_id` is consequently part of -the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated -rows use the legacy primary key. This avoids a lossy last-row-wins upsert. +their current-schema archive keys, while the dedicated legacy archive uses +`(source_profile, legacy_id)` from the original primary key. This avoids a lossy +last-row-wins upsert and keeps runtime current archives separate. Current-season actor/world/queue/lock/message/market/vote state is explicitly excluded. In particular, `general`, `city`, `nation`, their turn queues, @@ -108,35 +142,44 @@ season or as a substitute for the long-lived archive cutover procedure. archive owner from the game session and never accepts an owner ID from the browser. -- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with - `ng_games`, the latest matching `ng_old_nations` snapshot and an optional - `emperior` row. It returns summary fields and a link target for the existing - public dynasty/nation detail. -- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in - the database predicate before returning `data.history`. A foreign or missing - record uses the same not-found response. -- Legacy `data.history` may be either an array or a `
`-joined string. The API - normalizes both to a newest-first string array, and the frontend renders plain - text rather than archived markup. +- `archive.myPastPlays` reads the central legacy archive across profiles and + current runtime archives, tags each source, and suppresses a current-schema + duplicate when the central legacy copy exists. +- `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes + the session owner in the database predicate. A foreign or missing record uses + the same not-found response. +- The detail DTO feeds the same `GeneralBasicCard`, battle summary, + `LegacyGeneralProgress`, and record panels used by My Page/Battle Center. + Missing battle/mastery/log channels show an explicit not-preserved state. +- Legacy `data.history` may be either an array or a `
`-joined string. It is + normalized to plain-text archive entries; archived markup is never rendered + as trusted HTML. - Runtime death and unification archival writes the current general `GENERAL/HISTORY` rows into the same `data.history` field, so newly completed seasons remain compatible with imported rows. +Hall-of-fame and dynasty APIs and pages take an explicit `current` or `legacy` +source. Legacy results use the central archive and show the source profile; +they are never merged into the current rankings or current dynasty list. + ## Cutover procedure 1. Keep the original compressed dumps immutable and restore each source to a private MariaDB instance. 2. Deploy the gateway and game Prisma migrations to empty staging databases. -3. Run gateway and each non-empty profile without `--apply`; archive the JSON +3. Run gateway and each non-empty official profile without `--apply`; archive the JSON counts and excluded-table reasons. 4. Compare source counts, malformed JSON checks and duplicate natural-key counts. Stop on unexplained drift. 5. Put the affected target in maintenance mode, take a PostgreSQL backup, then run the same commands with `--apply`. -6. Repeat each apply. Counts must remain unchanged. -7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership, - old-nation/history duplicate preservation, `/past-plays` list/detail access, - foreign-owner denial and the dynasty link. +6. Repeat each apply. Counts must remain unchanged; verify the newest + `legacy_archive.import_run` is `COMPLETED` and current Gateway credentials + are unchanged. +7. Verify valid/invalid Kakao-ID classification, password-reset-required rows, + Kakao password setup, CLI fallback, archive ownership, canonical source + format counts, opening dates, `/past-plays`, foreign-owner denial, legacy + Hall and legacy Dynasty source switches. 8. Retain the MariaDB dumps as rollback evidence. Rollback restores the pre-cutover PostgreSQL backup; it does not reverse individual importer upserts. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 023e0239..3d68b05e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -25,6 +25,7 @@ export * from './ranking/types.js'; export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; export * from './logging/formatLegacyLogHtml.js'; +export * from './legacyArchive/ArchivedGeneralSnapshot.js'; export * from './gateway/profileStatus.js'; export * from './game/accessPenalty.js'; export * from './http/trpcTransport.js'; diff --git a/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts new file mode 100644 index 00000000..dab9e2b0 --- /dev/null +++ b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts @@ -0,0 +1,321 @@ +export type ArchivedJsonValue = + null | boolean | number | string | ArchivedJsonValue[] | { [key: string]: ArchivedJsonValue }; + +export const ARCHIVED_GENERAL_SCHEMA_VERSION = 1 as const; + +export const LEGACY_ARCHIVE_PROFILES = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const; +export type LegacyArchiveProfile = (typeof LEGACY_ARCHIVE_PROFILES)[number]; + +export const isLegacyArchiveProfile = (value: string): value is LegacyArchiveProfile => + (LEGACY_ARCHIVE_PROFILES as readonly string[]).includes(value); + +export type ArchivedGeneralSourceFormat = 'legacy-flat-v0' | 'ref-flat-v1' | 'core-snapshot-v1' | 'unknown'; + +export interface ArchivedGeneralSnapshotV1 { + schemaVersion: typeof ARCHIVED_GENERAL_SCHEMA_VERSION; + identity: { + name: string; + picture: string | null; + imageServer: number | null; + npcState: number | null; + nationId: number | null; + cityId: number | null; + officerLevel: number | null; + officerCity: number | null; + }; + stats: { + leadership: number | null; + strength: number | null; + intelligence: number | null; + leadershipExperience: number | null; + strengthExperience: number | null; + intelligenceExperience: number | null; + }; + progression: { + experience: number | null; + experienceLevel: number | null; + dedication: number | null; + dedicationLevel: number | null; + age: number | null; + startAge: number | null; + bornYear: number | null; + deadYear: number | null; + }; + traits: { + personality: string | null; + specialDomestic: string | null; + specialWar: string | null; + }; + resources: { + gold: number | null; + rice: number | null; + crew: number | null; + crewType: string | null; + train: number | null; + morale: number | null; + injury: number | null; + }; + items: { + horse: string | null; + weapon: string | null; + book: string | null; + item: string | null; + }; + mastery: { + infantry: number | null; + archery: number | null; + cavalry: number | null; + special: number | null; + siege: number | null; + }; + battle: { + battles: number | null; + wins: number | null; + losses: number | null; + fireSuccesses: number | null; + kills: number | null; + deaths: number | null; + killedCrew: number | null; + lostCrew: number | null; + winRate: number | null; + killRate: number | null; + recentWar: string | null; + tactics: { + total: { wins: number | null; draws: number | null; losses: number | null }; + leadership: { wins: number | null; draws: number | null; losses: number | null }; + intelligence: { wins: number | null; draws: number | null; losses: number | null }; + }; + }; + history: string[]; + availability: { + mastery: boolean; + battleAggregates: boolean; + tactics: boolean; + history: boolean; + battleDetailLogs: false; + battleResultLogs: false; + }; +} + +type JsonRecord = Record; + +const asRecord = (value: ArchivedJsonValue | undefined): JsonRecord => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {}; + +const finiteNumber = (value: ArchivedJsonValue | undefined): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const firstNumber = (...values: Array): number | null => { + for (const value of values) { + const parsed = finiteNumber(value); + if (parsed !== null) return parsed; + } + return null; +}; + +const text = (value: ArchivedJsonValue | undefined): string | null => { + if (typeof value === 'string') return value.trim() === '' ? null : value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +}; + +const firstText = (...values: Array): string | null => { + for (const value of values) { + const parsed = text(value); + if (parsed !== null) return parsed; + } + return null; +}; + +const historyLines = (value: ArchivedJsonValue | undefined): string[] => { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0); + } + if (typeof value !== 'string') return []; + return value + .split(//iu) + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +const rate = (numerator: number | null, denominator: number | null): number | null => + numerator === null || denominator === null || denominator <= 0 + ? null + : Math.round((numerator / denominator) * 10_000) / 100; + +export const detectArchivedGeneralSourceFormat = (raw: ArchivedJsonValue): ArchivedGeneralSourceFormat => { + const data = asRecord(raw); + if (Object.keys(asRecord(data.stats)).length > 0 || data.schemaVersion === 1) return 'core-snapshot-v1'; + if ('leadership' in data || 'strength' in data || 'dex1' in data) return 'ref-flat-v1'; + if ('leader' in data || 'power' in data || 'dex0' in data) return 'legacy-flat-v0'; + return 'unknown'; +}; + +export const normalizeArchivedGeneral = ( + raw: ArchivedJsonValue, + fallbackName: string +): { sourceFormat: ArchivedGeneralSourceFormat; snapshot: ArchivedGeneralSnapshotV1 } => { + const data = asRecord(raw); + const identity = asRecord(data.identity); + const stats = asRecord(data.stats); + const role = asRecord(data.role); + const items = asRecord(data.items); + const progression = asRecord(data.progression); + const traits = asRecord(data.traits); + const resources = asRecord(data.resources); + const nestedMastery = asRecord(data.mastery); + const nestedBattle = asRecord(data.battle); + const nestedTactics = asRecord(nestedBattle.tactics); + const totalTactics = asRecord(nestedTactics.total); + const leadershipTactics = asRecord(nestedTactics.leadership); + const intelligenceTactics = asRecord(nestedTactics.intelligence); + const oldMastery = 'dex0' in data; + const masteryValues = oldMastery + ? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40] + : [ + data.dex1 ?? nestedMastery.infantry, + data.dex2 ?? nestedMastery.archery, + data.dex3 ?? nestedMastery.cavalry, + data.dex4 ?? nestedMastery.special, + data.dex5 ?? nestedMastery.siege, + ]; + const mastery = masteryValues.map(finiteNumber); + const battles = firstNumber(data.warnum, nestedBattle.battles); + const wins = firstNumber(data.killnum, nestedBattle.wins); + const losses = firstNumber(data.deathnum, nestedBattle.losses); + const killedCrew = firstNumber(data.killcrew, nestedBattle.killedCrew); + const lostCrew = firstNumber(data.deathcrew, nestedBattle.lostCrew); + const history = historyLines(data.history); + const tacticValues = [ + data.ttw ?? totalTactics.wins, + data.ttd ?? totalTactics.draws, + data.ttl ?? totalTactics.losses, + data.tlw ?? leadershipTactics.wins, + data.tld ?? leadershipTactics.draws, + data.tll ?? leadershipTactics.losses, + data.tiw ?? intelligenceTactics.wins, + data.tid ?? intelligenceTactics.draws, + data.til ?? intelligenceTactics.losses, + ]; + const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null); + + return { + sourceFormat: detectArchivedGeneralSourceFormat(raw), + snapshot: { + schemaVersion: ARCHIVED_GENERAL_SCHEMA_VERSION, + identity: { + name: firstText(data.name, identity.name) ?? fallbackName, + picture: firstText(data.picture, identity.picture), + imageServer: firstNumber(data.imageServer, data.imgsvr, identity.imageServer), + npcState: firstNumber(data.npcState, data.npc, identity.npcState), + nationId: firstNumber(data.nationId, data.nation, identity.nationId), + cityId: firstNumber(data.cityId, data.city, identity.cityId), + officerLevel: firstNumber(data.officerLevel, data.officer_level, data.level, identity.officerLevel), + officerCity: firstNumber(data.officerCity, data.officer_city, identity.officerCity), + }, + stats: { + leadership: firstNumber(data.leadership, data.leader, stats.leadership), + strength: firstNumber(data.strength, data.power, stats.strength), + intelligence: firstNumber(data.intelligence, data.intel, stats.intelligence), + leadershipExperience: firstNumber( + data.leadershipExperience, + data.leadership_exp, + stats.leadershipExperience + ), + strengthExperience: firstNumber(data.strengthExperience, data.strength_exp, stats.strengthExperience), + intelligenceExperience: firstNumber( + data.intelligenceExperience, + data.intel_exp, + stats.intelligenceExperience + ), + }, + progression: { + experience: firstNumber(data.experience, progression.experience), + experienceLevel: firstNumber(data.experienceLevel, data.explevel, progression.experienceLevel), + dedication: firstNumber(data.dedication, progression.dedication), + dedicationLevel: firstNumber(data.dedicationLevel, data.dedlevel, progression.dedicationLevel), + age: firstNumber(data.age, progression.age), + startAge: firstNumber(data.startAge, data.startage, progression.startAge), + bornYear: firstNumber(data.bornYear, data.bornyear, progression.bornYear), + deadYear: firstNumber(data.deadYear, data.deadyear, progression.deadYear), + }, + traits: { + personality: firstText(data.personalCode, data.personal, role.personality, traits.personality), + specialDomestic: firstText( + data.specialCode, + data.special, + role.specialDomestic, + traits.specialDomestic + ), + specialWar: firstText(data.special2Code, data.special2, role.specialWar, traits.specialWar), + }, + resources: { + gold: firstNumber(data.gold, resources.gold), + rice: firstNumber(data.rice, resources.rice), + crew: firstNumber(data.crew, resources.crew), + crewType: firstText(data.crewType, data.crewtype, resources.crewType), + train: firstNumber(data.train, resources.train), + morale: firstNumber(data.morale, data.atmos, resources.morale), + injury: firstNumber(data.injury, resources.injury), + }, + items: { + horse: firstText(items.horse, data.horse), + weapon: firstText(items.weapon, data.weapon, data.weap), + book: firstText(items.book, data.book), + item: firstText(items.item, data.item), + }, + mastery: { + infantry: mastery[0] ?? null, + archery: mastery[1] ?? null, + cavalry: mastery[2] ?? null, + special: mastery[3] ?? null, + siege: mastery[4] ?? null, + }, + battle: { + battles, + wins, + losses, + fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses), + kills: firstNumber(nestedBattle.kills, wins), + deaths: firstNumber(nestedBattle.deaths, losses), + killedCrew, + lostCrew, + winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles), + killRate: firstNumber(nestedBattle.killRate) ?? rate(killedCrew, lostCrew), + recentWar: firstText(data.recentWar, data.recent_war, nestedBattle.recentWar), + tactics: { + total: { + wins: firstNumber(data.ttw, totalTactics.wins), + draws: firstNumber(data.ttd, totalTactics.draws), + losses: firstNumber(data.ttl, totalTactics.losses), + }, + leadership: { + wins: firstNumber(data.tlw, leadershipTactics.wins), + draws: firstNumber(data.tld, leadershipTactics.draws), + losses: firstNumber(data.tll, leadershipTactics.losses), + }, + intelligence: { + wins: firstNumber(data.tiw, intelligenceTactics.wins), + draws: firstNumber(data.tid, intelligenceTactics.draws), + losses: firstNumber(data.til, intelligenceTactics.losses), + }, + }, + }, + history, + availability: { + mastery: mastery.some((value) => value !== null), + battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null), + tactics: tacticsAvailable, + history: history.length > 0, + battleDetailLogs: false, + battleResultLogs: false, + }, + }, + }; +}; diff --git a/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql b/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql new file mode 100644 index 00000000..524f5d9f --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "app_user" + ADD COLUMN "password_reset_required" BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE "app_user" +SET "password_reset_required" = TRUE +WHERE "password_hash" ~ '^[[:xdigit:]]{128}$'; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 351cee68..8f2b1435 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -82,6 +82,7 @@ model AppUser { displayName String @unique @map("display_name") passwordHash String @map("password_hash") passwordSalt String @map("password_salt") + passwordResetRequired Boolean @default(false) @map("password_reset_required") roles Json @default(dbgenerated("'[]'::jsonb")) sanctions Json @default(dbgenerated("'{}'::jsonb")) oauthType OAuthType @default(NONE) @map("oauth_type") diff --git a/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql b/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql new file mode 100644 index 00000000..70e8d2a0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql @@ -0,0 +1,125 @@ +CREATE SCHEMA IF NOT EXISTS "legacy_archive"; + +CREATE TABLE IF NOT EXISTS "legacy_archive"."import_run" ( + "id" BIGSERIAL PRIMARY KEY, + "source_profile" TEXT NOT NULL, + "status" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finished_at" TIMESTAMP(3), + "counts" JSONB NOT NULL DEFAULT '{}'::jsonb, + "source_format_summary" JSONB NOT NULL DEFAULT '{}'::jsonb, + "error" TEXT, + CONSTRAINT "legacy_archive_import_run_profile_check" + CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')), + CONSTRAINT "legacy_archive_import_run_status_check" + CHECK ("status" IN ('RUNNING', 'COMPLETED', 'FAILED')) +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_import_run_profile_started" + ON "legacy_archive"."import_run" ("source_profile", "started_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."game_history" ( + "source_profile" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "opened_at" TIMESTAMP(3) NOT NULL, + "completed_at" TIMESTAMP(3), + "legacy_date" TIMESTAMP(3) NOT NULL, + "winner_nation" INTEGER, + "map" TEXT, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "scenario_name" TEXT NOT NULL, + "raw_env" JSONB NOT NULL DEFAULT '{}'::jsonb, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_game_history_opened" + ON "legacy_archive"."game_history" ("source_profile", "opened_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."general" ( + "source_profile" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "general_no" INTEGER NOT NULL, + "legacy_id" INTEGER NOT NULL, + "owner" TEXT, + "name" TEXT NOT NULL, + "last_yearmonth" INTEGER NOT NULL, + "turntime" TIMESTAMP(3) NOT NULL, + "schema_version" INTEGER NOT NULL DEFAULT 1, + "source_format" TEXT NOT NULL, + "data" JSONB NOT NULL, + "raw_data" JSONB NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id", "general_no"), + CONSTRAINT "legacy_archive_general_schema_version_check" CHECK ("schema_version" = 1) +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_general_owner_opened" + ON "legacy_archive"."general" ("owner", "source_profile", "server_id"); +CREATE INDEX IF NOT EXISTS "legacy_archive_general_name" + ON "legacy_archive"."general" ("source_profile", "server_id", "name"); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."nation" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT NOT NULL, + "nation" INTEGER NOT NULL, + "data" JSONB NOT NULL, + "archived_at" TIMESTAMP(3) NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_nation_server" + ON "legacy_archive"."nation" ("source_profile", "server_id", "nation", "archived_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."hall" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT NOT NULL, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "general_no" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "owner" TEXT, + "aux" JSONB NOT NULL DEFAULT '{}'::jsonb, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id", "type", "general_no") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_hall_scenario" + ON "legacy_archive"."hall" ("source_profile", "season", "scenario", "type", "value" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."emperor" ( + "id" BIGSERIAL PRIMARY KEY, + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT, + "data" JSONB NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + CONSTRAINT "legacy_archive_emperor_source_key" UNIQUE ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_emperor_server" + ON "legacy_archive"."emperor" ("source_profile", "server_id", "id" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."yearbook" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "profile_name" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "map" JSONB NOT NULL DEFAULT '{}'::jsonb, + "nations" JSONB NOT NULL DEFAULT '[]'::jsonb, + "global_history" JSONB NOT NULL DEFAULT '[]'::jsonb, + "global_action" JSONB NOT NULL DEFAULT '[]'::jsonb, + "content_hash" TEXT NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_yearbook_month" + ON "legacy_archive"."yearbook" ("source_profile", "profile_name", "year", "month", "legacy_id"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9707828c..bc274d90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,9 @@ importers: tools/legacy-db-migration: dependencies: + '@sammo-ts/common': + specifier: workspace:* + version: link:../../packages/common mariadb: specifier: 3.5.3 version: 3.5.3 diff --git a/release-manifest.json b/release-manifest.json index e5fda16b..dda2c243 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", - "gameSchemaHead": "20260816000000_add_read_model_change_journal", + "gatewaySchemaHead": "20260817000000_add_password_reset_required", + "gameSchemaHead": "20260817001000_add_dedicated_legacy_archive", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index 2752fbda..e8994fac 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator route invokes it. The default mode is a read-only dry-run. `--apply` is required before any target -write. PostgreSQL advisory locks prevent two applies for the same target. Every -write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted -run can be repeated. +write. PostgreSQL advisory locks prevent two applies for the same target. +Gateway writes are transactional. Game archive writes and their completed +`legacy_archive.import_run` record are transactional. Stable legacy keys make +completed or interrupted runs repeatable. ## Source restore @@ -37,6 +38,13 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate After reviewing the JSON counts and excluded-table reasons, add `GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`. +For game archives, `GAME_DATABASE_URL` points at that profile's Core schema. +The importer writes completed-history data to the shared +`legacy_archive` PostgreSQL schema and writes only inheritance projections to +the selected current profile schema. Accepted profiles are +`che,kwe,pwe,twe,nya,pya,hwe`; run them separately against the same PostgreSQL +database. + ### Isolated current-season comparison fixture `current-season-fixture` is separate from the long-lived archive migration. It @@ -77,13 +85,17 @@ listed in the JSON result. This fixture is evidence for persisted-state and GUI comparison, not proof that the two engines consume RNG identically after the next turn. -Kakao members retain their OAuth ID, email, and OAuth metadata. -`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time. +Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with +a non-empty OAuth ID receives `kakao_verified_at`. +`kakao_grace_started_at` is set to the migration time. The existing `token_valid_until` is copied to `kakao_talk_verified_until` for Kakao rows so a still-current “send to me” proof remains current after cutover. -Legacy password hashes and salts are retained and upgraded to Argon2id after -the first successful login when -`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api. +Imported 128-hex password hashes are marked for reset. They can be upgraded to +Argon2id after the first successful login only when the DB-external +`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is safely recovered. Otherwise, a verified +Kakao flow requires a new password before session issuance. A non-Kakao account +uses the CLI reset below. Reapplying a dump preserves the target account's +current credential, OAuth, identity, roles, sanctions, consent, and login state. Only tables present in the checked ref schemas are eligible. Extra tables found in a dump, such as an old root `config` table, are left in the recovery dump and diff --git a/tools/legacy-db-migration/package.json b/tools/legacy-db-migration/package.json index 4f53c8cc..428b3e85 100644 --- a/tools/legacy-db-migration/package.json +++ b/tools/legacy-db-migration/package.json @@ -15,6 +15,7 @@ "migrate": "tsx src/cli.ts" }, "dependencies": { + "@sammo-ts/common": "workspace:*", "mariadb": "3.5.3", "pg": "^8.16.3" }, diff --git a/tools/legacy-db-migration/src/cli.ts b/tools/legacy-db-migration/src/cli.ts index 69e93dc8..9a5b0dcb 100644 --- a/tools/legacy-db-migration/src/cli.ts +++ b/tools/legacy-db-migration/src/cli.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import process from 'node:process'; import { createMariaPool, createPostgresPool } from './db.js'; -import { migrateGame } from './game.js'; +import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, migrateGame } from './game.js'; import { migrateGateway } from './gateway.js'; import { hashPasswordForReset } from './password.js'; import { migrateCurrentSeasonFixture } from './currentSeason.js'; @@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise => { const migratedAt = new Date(); if (options.command === 'gateway') { const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL')); - const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null; + const targetUrl = process.env.GATEWAY_DATABASE_URL?.trim(); + if (options.apply && !targetUrl) { + throw new Error('GATEWAY_DATABASE_URL is required with --apply'); + } + const target = targetUrl ? createPostgresPool(targetUrl) : null; try { const summary = await migrateGateway(source, target, options.apply, migratedAt); console.log(JSON.stringify(summary, null, 2)); @@ -156,6 +163,9 @@ const run = async (): Promise => { if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) { throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`); } + if (options.command === 'game' && !isLegacyArchiveProfile(options.profile)) { + throw new Error(`game requires --profile ${LEGACY_ARCHIVE_PROFILES.join('|')}\n\n${usage}`); + } const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL')); const target = options.apply || options.command === 'current-season-fixture' diff --git a/tools/legacy-db-migration/src/db.ts b/tools/legacy-db-migration/src/db.ts index f969e5eb..8d06d62a 100644 --- a/tools/legacy-db-migration/src/db.ts +++ b/tools/legacy-db-migration/src/db.ts @@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => { return `"${value}"`; }; +export const quoteQualifiedIdentifier = (value: string): string => { + const parts = value.split('.'); + if (parts.length < 1 || parts.length > 2 || parts.some((part) => !IDENTIFIER.test(part))) { + throw new Error(`Unsafe SQL identifier: ${value}`); + } + return parts.map((part) => `"${part}"`).join('.'); +}; + export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri); export const createPostgresPool = (connectionString: string): pg.Pool => { @@ -94,7 +102,8 @@ export const upsertRows = async ( client: PoolClient, table: string, rows: readonly TargetRow[], - conflictColumns: readonly string[] + conflictColumns: readonly string[], + options: { preserveOnConflict?: readonly string[] } = {} ): Promise => { if (rows.length === 0) { return; @@ -116,12 +125,13 @@ export const upsertRows = async ( }); return `(${placeholders.join(', ')})`; }); + const preserved = new Set(options.preserveOnConflict ?? []); const updates = columns - .filter((column) => !conflictColumns.includes(column)) + .filter((column) => !conflictColumns.includes(column) && !preserved.has(column)) .map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`); const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING'; await client.query( - `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) + `INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) VALUES ${tuples.join(', ')} ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`, values diff --git a/tools/legacy-db-migration/src/game.ts b/tools/legacy-db-migration/src/game.ts index 2d5b27cc..6fcf0a85 100644 --- a/tools/legacy-db-migration/src/game.ts +++ b/tools/legacy-db-migration/src/game.ts @@ -3,6 +3,16 @@ import { createHash } from 'node:crypto'; import type { Pool as MariaPool } from 'mariadb'; import type { Pool as PgPool, PoolClient } from 'pg'; +import { + isLegacyArchiveProfile, + normalizeArchivedGeneral, + type ArchivedGeneralSourceFormat, + type ArchivedJsonValue, + type LegacyArchiveProfile, +} from '@sammo-ts/common'; + +export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common'; + import { paginateSource, jsonParameter, @@ -29,6 +39,12 @@ import { const batchSize = 250; +interface ArchiveMigrationContext { + profile: LegacyArchiveProfile; + importRunId: string; + sourceFormats: Record; +} + const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue => value === null || value === undefined ? fallback : parseJson(value, context); @@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => { return memberNo > 0 ? legacyUserId(memberNo) : null; }; -const hashYearbook = (row: TargetRow): string => - createHash('sha256') - .update( - JSON.stringify({ - map: row.map, - nations: row.nations, - globalHistory: row.global_history, - globalAction: row.global_action, - }) - ) - .digest('hex'); +const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string => + createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex'); + +const asJsonRecord = (value: JsonValue): Record => + value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {}; + +export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => { + const record = asJsonRecord(env); + const candidate = record.opentime ?? record.starttime; + return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context); +}; const migrateSimpleTable = async ( source: MariaPool, @@ -75,17 +91,24 @@ const migrateSimpleTable = async ( } }; -const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateHall = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'hall', 'id', - 'hall', - ['server_id', 'type', 'general_no'], + 'legacy_archive.hall', + ['source_profile', 'server_id', 'type', 'general_no'], (row) => { const sourceId = toNumber(row.id, 'hall.id'); return { + source_profile: archive.profile, + legacy_id: sourceId, server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`), season: toNumber(row.season, `hall.${sourceId}.season`), scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`), @@ -94,24 +117,36 @@ const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Recor value: toFloat(row.value, `hall.${sourceId}.value`), owner: ownerId(row.owner), aux: parseJson(row.aux, `hall.${sourceId}.aux`), + import_run_id: archive.importRunId, }; }, counts ); -const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateGames = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'ng_games', 'id', - 'ng_games', - ['server_id'], + 'legacy_archive.game_history', + ['source_profile', 'server_id'], (row) => { const sourceId = toNumber(row.id, 'ng_games.id'); + const legacyDate = toDate(row.date, `ng_games.${sourceId}.date`); + const env = parseJson(row.env, `ng_games.${sourceId}.env`); return { + source_profile: archive.profile, server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`), - date: toDate(row.date, `ng_games.${sourceId}.date`), + legacy_id: sourceId, + opened_at: resolveLegacyGameOpenedAt(env, legacyDate, `ng_games.${sourceId}.opened_at`), + completed_at: null, + legacy_date: legacyDate, winner_nation: row.winner_nation === null ? null @@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco season: toNumber(row.season, `ng_games.${sourceId}.season`), scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`), scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`), - env: parseJson(row.env, `ng_games.${sourceId}.env`), + raw_env: jsonParameter(env), + import_run_id: archive.importRunId, }; }, counts @@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco const migrateOldGenerals = ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => migrateSimpleTable( source, target, 'ng_old_generals', 'id', - 'ng_old_generals', - ['server_id', 'general_no'], + 'legacy_archive.general', + ['source_profile', 'server_id', 'general_no'], (row) => { const sourceId = toNumber(row.id, 'ng_old_generals.id'); + const name = toStringValue(row.name, `ng_old_generals.${sourceId}.name`); + const rawData = parseJson(row.data, `ng_old_generals.${sourceId}.data`); + const normalized = normalizeArchivedGeneral(rawData as ArchivedJsonValue, name); + archive.sourceFormats[normalized.sourceFormat] += 1; return { + source_profile: archive.profile, server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`), general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`), + legacy_id: sourceId, owner: ownerId(row.owner), - name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`), + name, last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`), turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`), - data: parseJson(row.data, `ng_old_generals.${sourceId}.data`), + schema_version: normalized.snapshot.schemaVersion, + source_format: normalized.sourceFormat, + data: jsonParameter(normalized.snapshot), + raw_data: jsonParameter(rawData), + import_run_id: archive.importRunId, }; }, counts @@ -156,41 +203,47 @@ const migrateOldGenerals = ( const migrateOldNations = ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => migrateSimpleTable( source, target, 'ng_old_nations', 'id', - 'ng_old_nations', - ['server_id', 'nation', 'source_id'], + 'legacy_archive.nation', + ['source_profile', 'legacy_id'], (row) => { const sourceId = toNumber(row.id, 'ng_old_nations.id'); return { + source_profile: archive.profile, + legacy_id: sourceId, server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`), nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`), - source_id: sourceId, - data: parseJson(row.data, `ng_old_nations.${sourceId}.data`), - date: toDate(row.date, `ng_old_nations.${sourceId}.date`), + data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)), + archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`), + import_run_id: archive.importRunId, }; }, counts ); -const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateEmperors = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'emperior', 'no', - 'emperior', - ['legacy_id'], + 'legacy_archive.emperor', + ['source_profile', 'legacy_id'], (row) => { const id = toNumber(row.no, 'emperior.no'); - return { - legacy_id: id, - server_id: toNullableString(row.server_id), + const data = { phase: toNullableString(row.phase), nation_count: toNullableString(row.nation_count), nation_name: toNullableString(row.nation_name), @@ -232,6 +285,13 @@ const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: R history: parseNullableJson(row.history, [], `emperior.${id}.history`), aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`), }; + return { + source_profile: archive.profile, + legacy_id: id, + server_id: toNullableString(row.server_id), + data: jsonParameter(data), + import_run_id: archive.importRunId, + }; }, counts ); @@ -295,30 +355,35 @@ const migrateUserRecords = ( const migrateYearbook = async ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => { await migrateSimpleTable( source, target, 'ng_history', 'no', - 'yearbook_history', - ['profile_name', 'year', 'month', 'source_id'], + 'legacy_archive.yearbook', + ['source_profile', 'legacy_id'], (row) => { const id = toNumber(row.no, 'ng_history.no'); + const map = parseNullableJson(row.map, {}, `ng_history.${id}.map`); + const nations = parseNullableJson(row.nations, [], `ng_history.${id}.nations`); + const globalHistory = parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`); + const globalAction = parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`); const mapped: TargetRow = { + source_profile: archive.profile, + legacy_id: id, profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`), - source_id: id, year: toNumber(row.year, `ng_history.${id}.year`), month: toNumber(row.month, `ng_history.${id}.month`), - map: parseNullableJson(row.map, {}, `ng_history.${id}.map`), - nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`), - global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`), - global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`), - hash: '', - created_at: new Date(0), + map: jsonParameter(map), + nations: jsonParameter(nations), + global_history: jsonParameter(globalHistory), + global_action: jsonParameter(globalAction), + content_hash: hashYearbook(map, nations, globalHistory, globalAction), + import_run_id: archive.importRunId, }; - mapped.hash = hashYearbook(mapped); return mapped; }, counts, @@ -388,7 +453,16 @@ export const migrateGame = async ( apply: boolean, profile: string ): Promise => { + if (!isLegacyArchiveProfile(profile)) { + throw new Error(`Unsupported legacy archive profile: ${profile}`); + } const counts: Record = {}; + const sourceFormats: Record = { + 'legacy-flat-v0': 0, + 'ref-flat-v1': 0, + 'core-snapshot-v1': 0, + unknown: 0, + }; const excluded = { general: 'Current-season actor state is intentionally not transferred.', city: 'Current-season world state is intentionally not transferred.', @@ -421,25 +495,59 @@ export const migrateGame = async ( 'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.', }; const client = apply && targetPool ? await targetPool.connect() : null; + let importRunId: string | null = null; try { - const run = async (): Promise => { - await migrateGames(source, client, counts); - await migrateHall(source, client, counts); - await migrateOldGenerals(source, client, counts); - await migrateOldNations(source, client, counts); - await migrateEmperors(source, client, counts); + const run = async (archive: ArchiveMigrationContext): Promise => { + await migrateGames(source, client, counts, archive); + await migrateHall(source, client, counts, archive); + await migrateOldGenerals(source, client, counts, archive); + await migrateOldNations(source, client, counts, archive); + await migrateEmperors(source, client, counts, archive); await migrateInheritanceResults(source, client, counts); await migrateUserRecords(source, client, counts); await migrateStorage(source, client, counts); - await migrateYearbook(source, client, counts); + await migrateYearbook(source, client, counts, archive); }; if (client) { - await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run); + await withMigrationLock(client, `sammo-legacy-archive-v2:${profile}`, async () => { + const created = await client.query<{ id: string }>( + `INSERT INTO "legacy_archive"."import_run" ("source_profile", "status") + VALUES ($1, 'RUNNING') RETURNING "id"`, + [profile] + ); + importRunId = created.rows[0]?.id ?? null; + if (!importRunId) throw new Error('Failed to create legacy archive import run'); + const archive: ArchiveMigrationContext = { profile, importRunId, sourceFormats }; + await client.query('BEGIN'); + try { + await run(archive); + await client.query( + `UPDATE "legacy_archive"."import_run" + SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP, + "counts" = $2::jsonb, "source_format_summary" = $3::jsonb + WHERE "id" = $1`, + [importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats)] + ); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + const message = + error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000); + await client.query( + `UPDATE "legacy_archive"."import_run" + SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP, + "counts" = $2::jsonb, "source_format_summary" = $3::jsonb, "error" = $4 + WHERE "id" = $1`, + [importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats), message] + ); + throw error; + } + }); } else { - await run(); + await run({ profile, importRunId: '0', sourceFormats }); } } finally { client?.release(); } - return { command: 'game', apply, counts, excluded }; + return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats }; }; diff --git a/tools/legacy-db-migration/src/gateway.ts b/tools/legacy-db-migration/src/gateway.ts index 21f8c06e..2dc56dd6 100644 --- a/tools/legacy-db-migration/src/gateway.ts +++ b/tools/legacy-db-migration/src/gateway.ts @@ -24,17 +24,83 @@ export interface MigrationSummary { apply: boolean; counts: Record; excluded: Record; + importRunId?: string | null; + sourceFormatSummary?: Record; } const batchSize = 500; -const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => { +export const MEMBER_PRESERVED_COLUMNS = [ + 'login_id', + 'display_name', + 'password_hash', + 'password_salt', + 'password_reset_required', + 'roles', + 'sanctions', + 'oauth_type', + 'oauth_id', + 'email', + 'oauth_info', + 'picture', + 'image_server', + 'icon_updated_at', + 'third_party_use', + 'terms_accepted_at', + 'privacy_accepted_at', + 'kakao_verified_at', + 'kakao_talk_verified_until', + 'kakao_grace_started_at', + 'delete_after', + 'updated_at', + 'last_login_at', + 'created_at', +] as const; + +export const preflightMemberConflicts = async (target: PoolClient, rows: readonly TargetRow[]): Promise => { + if (rows.length === 0) return; + const ids = rows.map((row) => String(row.id)); + const loginIds = rows.map((row) => String(row.login_id)); + const displayNames = rows.map((row) => String(row.display_name)); + const emails = rows.map((row) => row.email).filter((value): value is string => typeof value === 'string'); + const existing = await target.query<{ + id: string; + login_id: string; + display_name: string; + email: string | null; + }>( + `SELECT "id", "login_id", "display_name", "email" + FROM "app_user" + WHERE "id" = ANY($1::text[]) + OR "login_id" = ANY($2::text[]) + OR "display_name" = ANY($3::text[]) + OR "email" = ANY($4::text[])`, + [ids, loginIds, displayNames, emails] + ); + for (const row of rows) { + const id = String(row.id); + const collision = existing.rows.find( + (candidate) => + candidate.id !== id && + (candidate.login_id === row.login_id || + candidate.display_name === row.display_name || + (row.email !== null && candidate.email === row.email)) + ); + if (collision) { + throw new Error('Target account identity collision in legacy member batch'); + } + } +}; + +export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => { const memberNo = toNumber(row.NO, 'member.NO'); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const acl = parseJson(row.acl, `member.${memberNo}.acl`); const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`); const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`); const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE'; + const oauthId = toNullableString(row.oauth_id)?.trim() || null; + const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`); const legacyData: JsonValue = { memberNo, grade, @@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): id: legacyUserId(memberNo), login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(), display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`), - password_hash: toStringValue(row.PW, `member.${memberNo}.PW`), + password_hash: passwordHash, password_salt: toStringValue(row.salt, `member.${memberNo}.salt`), + password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash), roles: jsonParameter(mapLegacyRoles(grade, acl)), sanctions: jsonParameter(mapLegacySanctions(grade, penalty)), oauth_type: oauthType, - oauth_id: toNullableString(row.oauth_id), + oauth_id: oauthId, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, oauth_info: jsonParameter(oauthInfo), picture: toNullableString(row.PICTURE) ?? 'default.jpg', @@ -63,9 +130,9 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0, terms_accepted_at: null, privacy_accepted_at: null, - kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null, + kakao_verified_at: oauthType === 'KAKAO' && oauthId ? migratedAt : null, kakao_talk_verified_until: - oauthType === 'KAKAO' + oauthType === 'KAKAO' && oauthId ? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`) : null, kakao_grace_started_at: migratedAt, @@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise> => const processMembers = async ( source: MariaPool, target: PoolClient | null, + apply: boolean, migratedAt: Date, counts: Record ): Promise => { @@ -103,7 +171,10 @@ const processMembers = async ( return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null); }); if (target) { - await upsertRows(target, 'app_user', mapped, ['id']); + await preflightMemberConflicts(target, mapped); + } + if (target && apply) { + await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS }); } counts.member = (counts.member ?? 0) + mapped.length; } @@ -206,17 +277,26 @@ export const migrateGateway = async ( login_token: 'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.', }; - const client = apply && targetPool ? await targetPool.connect() : null; + const client = targetPool ? await targetPool.connect() : null; try { const run = async (): Promise => { - await processMembers(source, client, migratedAt, counts); - await processMemberLogs(source, client, counts); - await processBannedMembers(source, client, counts); - await processRootKeyValues(source, client, counts); - await processSystem(source, client, counts); + await processMembers(source, client, apply, migratedAt, counts); + await processMemberLogs(source, apply ? client : null, counts); + await processBannedMembers(source, apply ? client : null, counts); + await processRootKeyValues(source, apply ? client : null, counts); + await processSystem(source, apply ? client : null, counts); }; - if (client) { - await withMigrationLock(client, 'sammo-legacy-gateway-v1', run); + if (client && apply) { + await withMigrationLock(client, 'sammo-legacy-gateway-v1', async () => { + await client.query('BEGIN'); + try { + await run(); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + }); } else { await run(); } diff --git a/tools/legacy-db-migration/test/archiveGeneral.test.ts b/tools/legacy-db-migration/test/archiveGeneral.test.ts new file mode 100644 index 00000000..b77e838e --- /dev/null +++ b/tools/legacy-db-migration/test/archiveGeneral.test.ts @@ -0,0 +1,71 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; + +const fixture = async (name: string): Promise => + JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue; + +describe('normalizeArchivedGeneral', () => { + it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => { + const { sourceFormat, snapshot } = normalizeArchivedGeneral( + await fixture('che-old-general-legacy-flat-v0.json'), + 'fallback' + ); + + expect(sourceFormat).toBe('legacy-flat-v0'); + expect(snapshot).toMatchObject({ + schemaVersion: 1, + identity: { name: '구형테스트장수', nationId: 3 }, + stats: { leadership: 81, strength: 73, intelligence: 66 }, + mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 }, + battle: { + battles: 20, + wins: 12, + losses: 8, + winRate: 60, + killRate: 125, + tactics: { total: { wins: 3, draws: 1, losses: 2 } }, + }, + history: ['●첫 기록', '●둘째 기록'], + availability: { mastery: true, battleAggregates: true, tactics: true }, + }); + expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu); + }); + + it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => { + const { sourceFormat, snapshot } = normalizeArchivedGeneral( + await fixture('hwe-old-general-ref-flat-v1.json'), + 'fallback' + ); + + expect(sourceFormat).toBe('ref-flat-v1'); + expect(snapshot).toMatchObject({ + identity: { name: '신형테스트장수', officerLevel: 7 }, + stats: { + leadership: 91, + strength: 82, + intelligence: 74, + leadershipExperience: 11, + }, + traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' }, + mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 }, + battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null }, + availability: { mastery: true, battleAggregates: false, tactics: false }, + }); + expect(snapshot.availability.battleDetailLogs).toBe(false); + expect(snapshot.availability.battleResultLogs).toBe(false); + }); + + it('keeps an already-normalized version 1 snapshot stable', async () => { + const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback'); + const second = normalizeArchivedGeneral( + first.snapshot as unknown as ArchivedJsonValue, + first.snapshot.identity.name + ); + + expect(second.sourceFormat).toBe('core-snapshot-v1'); + expect(second.snapshot).toEqual(first.snapshot); + }); +}); diff --git a/tools/legacy-db-migration/test/db.test.ts b/tools/legacy-db-migration/test/db.test.ts new file mode 100644 index 00000000..90d9f90d --- /dev/null +++ b/tools/legacy-db-migration/test/db.test.ts @@ -0,0 +1,24 @@ +import type { PoolClient } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js'; + +describe('qualified archive identifiers', () => { + it('quotes a schema-qualified table and still parameterizes values', async () => { + const query = vi.fn().mockResolvedValue({}); + await upsertRows( + { query } as unknown as PoolClient, + 'legacy_archive.general', + [{ id: 1, data: { ok: true } }], + ['id'] + ); + expect(query).toHaveBeenCalledOnce(); + expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"'); + expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]); + }); + + it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])( + 'rejects unsafe qualified identifier %s', + (value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier') + ); +}); diff --git a/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json b/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json new file mode 100644 index 00000000..38e144fd --- /dev/null +++ b/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json @@ -0,0 +1,50 @@ +{ + "name": "구형테스트장수", + "leader": 81, + "power": 73, + "intel": 66, + "leader2": 4, + "power2": 5, + "intel2": 6, + "nation": 3, + "city": 7, + "level": 8, + "personal": 2, + "special": 4, + "special2": 5, + "experience": 12345, + "explevel": 8, + "dedication": 765, + "dedlevel": 4, + "dex0": 101, + "dex10": 202, + "dex20": 303, + "dex30": 404, + "dex40": 505, + "warnum": 20, + "killnum": 12, + "deathnum": 8, + "firenum": 7, + "killcrew": 2500, + "deathcrew": 2000, + "ttw": 3, + "ttd": 1, + "ttl": 2, + "tlw": 4, + "tld": 0, + "tll": 1, + "tiw": 5, + "tid": 2, + "til": 3, + "picture": "default.jpg", + "imgsvr": 0, + "horse": 1, + "weap": 2, + "book": 3, + "item": 4, + "history": "●첫 기록
●둘째 기록
", + "recent_war": "2020-01-02 03:04:05", + "ip": "192.0.2.1", + "lastconnect": "2020-01-02 03:04:05", + "refresh": 10 +} diff --git a/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json b/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json new file mode 100644 index 00000000..ae398572 --- /dev/null +++ b/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json @@ -0,0 +1,36 @@ +{ + "name": "신형테스트장수", + "leadership": 91, + "strength": 82, + "intel": 74, + "leadership_exp": 11, + "strength_exp": 12, + "intel_exp": 13, + "nation": 4, + "city": 8, + "officer_level": 7, + "officer_city": 8, + "personal": "che_의리", + "special": "che_상재", + "special2": "che_신산", + "experience": 22222, + "explevel": 9, + "dedication": 999, + "dedlevel": 5, + "dex1": 111, + "dex2": 222, + "dex3": 333, + "dex4": 444, + "dex5": 555, + "picture": "default.jpg", + "imgsvr": 1, + "horse": "che_적토마", + "weapon": "che_청룡언월도", + "book": null, + "item": null, + "history": ["●최신 기록", "●이전 기록"], + "recent_war": null, + "aux": "", + "ip": "198.51.100.1", + "lastconnect": "2026-01-02 03:04:05" +} diff --git a/tools/legacy-db-migration/test/game.test.ts b/tools/legacy-db-migration/test/game.test.ts new file mode 100644 index 00000000..407d32ed --- /dev/null +++ b/tools/legacy-db-migration/test/game.test.ts @@ -0,0 +1,146 @@ +import type { Pool as MariaPool } from 'mariadb'; +import type { Pool as PgPool, PoolClient, QueryResult } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js'; + +const sourceRows = { + ng_games: [ + { + id: 1, + server_id: 'che_fixture_001', + date: new Date('2020-01-01T00:00:00.000Z'), + winner_nation: 3, + map: 'che', + season: 1, + scenario: 2, + scenario_name: 'fixture', + env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }), + }, + ], + ng_old_generals: [ + { + id: 2, + server_id: 'che_fixture_001', + general_no: 10, + owner: 42, + name: 'fixture-general', + last_yearmonth: 22012, + turntime: new Date('2020-02-01T00:00:00.000Z'), + data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first
second
' }), + }, + ], +} satisfies Record>>; + +const sourcePool = (): MariaPool => { + const seen = new Set(); + return { + query: vi.fn(async (sql: string) => { + const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? ''; + if (seen.has(table)) return []; + seen.add(table); + return sourceRows[table as keyof typeof sourceRows] ?? []; + }), + } as unknown as MariaPool; +}; + +const targetPool = (failPattern?: string) => { + const queries: Array<{ sql: string; values: readonly unknown[] }> = []; + const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => { + queries.push({ sql, values }); + if (failPattern && sql.includes(failPattern)) { + failPattern = undefined; + throw new Error('synthetic archive write failure'); + } + if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) { + return { rows: [{ id: '77' }], rowCount: 1 } as QueryResult<{ id: string }>; + } + return { rows: [], rowCount: 0 } as unknown as QueryResult; + }); + const client = { query, release: vi.fn() } as unknown as PoolClient; + return { + pool: { connect: vi.fn(async () => client) } as unknown as PgPool, + queries, + }; +}; + +describe('legacy archive game migration', () => { + it('uses the official profile allowlist and resolves the best opening timestamp', () => { + expect(isLegacyArchiveProfile('che')).toBe(true); + expect(isLegacyArchiveProfile('hwe')).toBe(true); + expect(isLegacyArchiveProfile('custom')).toBe(false); + expect( + resolveLegacyGameOpenedAt( + { opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }, + new Date('2020-01-01T00:00:00.000Z'), + 'fixture' + ).toISOString() + ).toBe('2020-01-02T00:00:00.000Z'); + expect( + resolveLegacyGameOpenedAt( + { starttime: '2020-01-03T00:00:00.000Z' }, + new Date('2020-01-01T00:00:00.000Z'), + 'fixture' + ).toISOString() + ).toBe('2020-01-03T00:00:00.000Z'); + expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe( + '2020-01-01T00:00:00.000Z' + ); + }); + + it('keeps dry-run target-read-only while reporting normalized formats', async () => { + const summary = await migrateGame(sourcePool(), null, false, 'che'); + expect(summary).toMatchObject({ + apply: false, + importRunId: null, + counts: { ng_games: 1, ng_old_generals: 1 }, + sourceFormatSummary: { 'legacy-flat-v0': 1 }, + }); + }); + + it('records a completed import run and writes only archive tables for historical snapshots', async () => { + const target = targetPool(); + const summary = await migrateGame(sourcePool(), target.pool, true, 'che'); + const sql = target.queries.map((entry) => entry.sql).join('\n'); + + expect(summary.importRunId).toBe('77'); + expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"'); + expect(sql).toContain('INSERT INTO "legacy_archive"."general"'); + expect(sql).not.toContain('INSERT INTO "ng_games"'); + expect(sql).not.toContain('INSERT INTO "ng_old_generals"'); + expect(sql).toContain(`SET "status" = 'COMPLETED'`); + expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true); + expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true); + expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan( + target.queries.findIndex((entry) => entry.sql === 'COMMIT') + ); + }); + + it('rolls back archive writes and records a failed import run', async () => { + const target = targetPool('INSERT INTO "legacy_archive"."general"'); + await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow( + 'synthetic archive write failure' + ); + const sql = target.queries.map((entry) => entry.sql).join('\n'); + expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true); + expect(sql).toContain(`SET "status" = 'FAILED'`); + expect(sql).not.toContain(`SET "status" = 'COMPLETED'`); + }); + + it('rolls back archive writes when completing the import run fails', async () => { + const target = targetPool(`SET "status" = 'COMPLETED'`); + await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow( + 'synthetic archive write failure' + ); + + expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false); + expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true); + expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true); + }); + + it('rejects an unsupported profile before reading or writing', async () => { + await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow( + 'Unsupported legacy archive profile' + ); + }); +}); diff --git a/tools/legacy-db-migration/test/gateway.test.ts b/tools/legacy-db-migration/test/gateway.test.ts new file mode 100644 index 00000000..e5e94704 --- /dev/null +++ b/tools/legacy-db-migration/test/gateway.test.ts @@ -0,0 +1,113 @@ +import type { PoolClient } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { upsertRows } from '../src/db.js'; +import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js'; + +const memberRow = (overrides: Record = {}) => ({ + NO: 7, + GRADE: 1, + acl: '{}', + penalty: '{}', + oauth_info: '{}', + oauth_type: 'KAKAO', + oauth_id: null, + PW: 'a'.repeat(128), + salt: 'member-salt', + ID: 'LegacyUser', + NAME: '레거시유저', + EMAIL: 'USER@EXAMPLE.TEST', + PICTURE: 'default.jpg', + IMGSVR: 0, + third_use: 0, + token_valid_until: null, + delete_after: null, + REG_DATE: '2020-01-01 00:00:00', + REG_NUM: 0, + BLOCK_NUM: 0, + BLOCK_DATE: null, + ...overrides, +}); + +describe('legacy gateway member migration', () => { + it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => { + const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null); + + expect(mapped).toMatchObject({ + login_id: 'legacyuser', + email: 'user@example.test', + password_reset_required: true, + oauth_type: 'KAKAO', + oauth_id: null, + kakao_verified_at: null, + }); + }); + + it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => { + const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 })); + const client = { query } as unknown as PoolClient; + + await upsertRows( + client, + 'app_user', + [ + { + id: 'legacy-id', + login_id: 'legacy-user', + password_hash: 'legacy-hash', + oauth_info: '{}', + legacy_data: '{}', + }, + ], + ['id'], + { preserveOnConflict: ['password_hash', 'oauth_info'] } + ); + + const sql = String(query.mock.calls[0]?.[0]); + expect(sql).toContain('"login_id" = EXCLUDED."login_id"'); + expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"'); + expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"'); + expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"'); + }); + + it('preserves renamed login and display identities along with every live credential field', () => { + expect(MEMBER_PRESERVED_COLUMNS).toEqual( + expect.arrayContaining([ + 'login_id', + 'display_name', + 'password_hash', + 'password_salt', + 'password_reset_required', + 'roles', + 'sanctions', + 'oauth_id', + 'email', + 'updated_at', + 'last_login_at', + 'created_at', + ]) + ); + expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data'); + }); + + it('rejects a source member when another target account already owns its identity', async () => { + const query = vi.fn(async (..._args: unknown[]) => ({ + rows: [ + { + id: 'another-target-id', + login_id: 'legacyuser', + display_name: '다른사용자', + email: 'other@example.test', + }, + ], + rowCount: 1, + })); + const client = { query } as unknown as PoolClient; + const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null); + + await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow( + 'Target account identity collision in legacy member batch' + ); + expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"'); + }); +});