feat: 레거시 재이관과 계정 복구 기반을 추가

중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
2026-08-17 15:55:32 +00:00
parent 50e7d894e4
commit fc7de05017
33 changed files with 1720 additions and 150 deletions
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
kakaoGraceStartedAt: now.toISOString(), kakaoGraceStartedAt: now.toISOString(),
passwordSalt: password.salt, passwordSalt: password.salt,
passwordHash: password.hash, passwordHash: password.hash,
passwordResetRequired: false,
createdAt: now.toISOString(), createdAt: now.toISOString(),
}; };
usersByName.set(input.username, user); usersByName.set(input.username, user);
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const upgraded = await hasher.hash(password); const upgraded = await hasher.hash(password);
user.passwordSalt = upgraded.salt; user.passwordSalt = upgraded.salt;
user.passwordHash = upgraded.hash; user.passwordHash = upgraded.hash;
user.passwordResetRequired = false;
} }
return verified.ok; return verified.ok;
}, },
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const next = await hasher.hash(password); const next = await hasher.hash(password);
user.passwordSalt = next.salt; user.passwordSalt = next.salt;
user.passwordHash = next.hash; user.passwordHash = next.hash;
user.passwordResetRequired = false;
return; return;
} }
} }
+11 -6
View File
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
grants: readonly SpecialAccountAccessGrantRecord[], grants: readonly SpecialAccountAccessGrantRecord[],
now: Date = new Date() now: Date = new Date()
): boolean => ): 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 => const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName); 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 selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
const expiresAt = active.some((grant) => !grant.expiresAt) const expiresAt = active.some((grant) => !grant.expiresAt)
? null ? null
: active : (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
.map((grant) => grant.expiresAt!)
.sort((left, right) => right.localeCompare(left))[0] ?? null;
return { return {
kind: selected.kind, kind: selected.kind,
grantId: selected.id, grantId: selected.id,
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
'localAccountGeneralCreationGraceDays', 'localAccountGeneralCreationGraceDays',
generalCreationDefault 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 graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date(); const now = options.now ?? new Date();
const specialAccess = resolveSpecialAccess({ const specialAccess = resolveSpecialAccess({
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS); const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt; const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral = const canCreateGeneral =
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt); kakaoVerified ||
specialAccess?.allowsGeneralCreation === true ||
(accessAllowed && now < generalCreationEndsAt);
return { return {
requiresKakaoVerification: !kakaoVerified && specialAccess === null, requiresKakaoVerification: !kakaoVerified && specialAccess === null,
+11 -4
View File
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
export interface OAuthSession { export interface OAuthSession {
id: string; id: string;
mode: OAuthMode; mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin'; intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
targetUserId?: string; targetUserId?: string;
kakaoId: string; kakaoId: string;
email: string; email: string;
@@ -93,6 +93,14 @@ end
return cjson.encode({ status = 'verified', userId = challenge.userId }) 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 { export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly client: RedisClientLike; private readonly client: RedisClientLike;
private readonly prefix: string; private readonly prefix: string;
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
async consumeSession(sessionId: string): Promise<OAuthSession | null> { async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId); const key = this.sessionKey(sessionId);
const raw = await this.client.get(key); const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
if (!raw) { if (!raw) {
return null; return null;
} }
await this.client.del(key); return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
return parseJson<OAuthSession>(raw);
} }
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> { async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
@@ -59,6 +59,7 @@ const mapUser = (row: {
displayName: string; displayName: string;
passwordHash: string; passwordHash: string;
passwordSalt: string; passwordSalt: string;
passwordResetRequired: boolean;
roles: GatewayPrisma.JsonValue; roles: GatewayPrisma.JsonValue;
sanctions: GatewayPrisma.JsonValue; sanctions: GatewayPrisma.JsonValue;
oauthType: 'NONE' | 'KAKAO'; oauthType: 'NONE' | 'KAKAO';
@@ -107,6 +108,7 @@ const mapUser = (row: {
deleteAfter: row.deleteAfter?.toISOString(), deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash, passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt, passwordSalt: row.passwordSalt,
passwordResetRequired: row.passwordResetRequired,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
legacyMemberNo: readLegacyMemberNo(row.legacyData), legacyMemberNo: readLegacyMemberNo(row.legacyData),
legacyGrade: readLegacyGrade(row.legacyData), legacyGrade: readLegacyGrade(row.legacyData),
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
displayName: input.displayName ?? input.username, displayName: input.displayName ?? input.username,
passwordHash: password.hash, passwordHash: password.hash,
passwordSalt: password.salt, passwordSalt: password.salt,
passwordResetRequired: false,
roles: ['user'] satisfies GatewayPrisma.JsonArray, roles: ['user'] satisfies GatewayPrisma.JsonArray,
sanctions: {} satisfies GatewayPrisma.JsonObject, sanctions: {} satisfies GatewayPrisma.JsonObject,
oauthType, oauthType,
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
data: { data: {
passwordHash: upgraded.hash, passwordHash: upgraded.hash,
passwordSalt: upgraded.salt, passwordSalt: upgraded.salt,
passwordResetRequired: false,
}, },
}); });
user.passwordHash = upgraded.hash; user.passwordHash = upgraded.hash;
user.passwordSalt = upgraded.salt; user.passwordSalt = upgraded.salt;
user.passwordResetRequired = false;
} }
return verified.ok; return verified.ok;
}, },
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
data: { data: {
passwordHash: next.hash, passwordHash: next.hash,
passwordSalt: next.salt, passwordSalt: next.salt,
passwordResetRequired: false,
}, },
}); });
}, },
+2 -1
View File
@@ -24,6 +24,7 @@ export interface UserRecord {
deleteAfter?: string; deleteAfter?: string;
passwordHash: string; passwordHash: string;
passwordSalt: string; passwordSalt: string;
passwordResetRequired: boolean;
createdAt: string; createdAt: string;
legacyMemberNo?: number; legacyMemberNo?: number;
legacyGrade?: number; legacyGrade?: number;
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
displayName: user.displayName, displayName: user.displayName,
roles: user.roles, roles: user.roles,
picture: user.picture, picture: user.picture,
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt, kakaoGraceStartedAt: user.kakaoGraceStartedAt,
createdAt: user.createdAt, createdAt: user.createdAt,
}); });
+102 -3
View File
@@ -91,6 +91,42 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
}; };
}; };
const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verified'>(
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({ export const appRouter = router({
health: router({ health: router({
ping: procedure.query(() => ({ ping: procedure.query(() => ({
@@ -348,7 +384,7 @@ export const appRouter = router({
} }
const refreshed = (await ctx.users.findById(verified.id)) ?? verified; const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-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') { if (pending.mode === 'change_pw') {
@@ -415,7 +451,7 @@ export const appRouter = router({
cause: error, cause: error,
}); });
} }
return finishKakaoLogin(ctx, synced, token.accessToken, 'login'); return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
} }
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
@@ -562,7 +598,70 @@ export const appRouter = router({
}); });
} }
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); 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 register: procedure
.input( .input(
+114 -2
View File
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
}); });
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => { 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({ const emailOwner = await users.createUser({
username: 'email-owner', username: 'email-owner',
password: 'owner-password', password: 'owner-password',
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
info: {}, info: {},
}, },
}); });
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
kakaoProfile.id = 'different-kakao-id'; kakaoProfile.id = 'different-kakao-id';
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
oauthSessionId: recovery.oauthSessionId, oauthSessionId: recovery.oauthSessionId,
action: 'link_existing', 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(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
email: 'tester@example.com', email: 'tester@example.com',
}); });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); 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 () => { 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', username: 'self-service',
password: 'current-password', password: 'current-password',
}); });
user.passwordResetRequired = true;
const session = await sessions.createSession(user); const session = await sessions.createSession(user);
await expect( await expect(
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
const refreshed = await users.findById(user.id); const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true); 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 () => { it('revokes the session and schedules deletion after 30 days', async () => {
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
kakaoGraceStartedAt: graceStartedAt.toISOString(), kakaoGraceStartedAt: graceStartedAt.toISOString(),
passwordHash: 'unused', passwordHash: 'unused',
passwordSalt: '', passwordSalt: '',
passwordResetRequired: false,
createdAt: graceStartedAt.toISOString(), 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', () => { it('extends account access with an administrator override without widening general creation grace', () => {
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z')); const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z'; user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
}); });
sessionIds.add(session.id); 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, id: session.id,
intent: 'link_existing', intent: 'link_existing',
targetUserId, targetUserId,
email: 'retained@example.test', email: 'retained@example.test',
}); });
await expect(store.consumeSession(session.id)).resolves.toBeNull();
}); });
it('atomically consumes a successful code once', async () => { it('atomically consumes a successful code once', async () => {
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
}); });
user.passwordSalt = 'core-salt'; user.passwordSalt = 'core-salt';
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex'); user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe(''); expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
}); });
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => { 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'); const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt; user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex'); user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe(''); expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
}); });
it('does not accept an imported ref credential without the matching global salt', async () => { it('does not accept an imported ref credential without the matching global salt', async () => {
+3 -7
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', gatewaySchemaHead: '20260817000000_add_password_reset_required',
gameSchemaHead: '20260816000000_add_read_model_change_journal', 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 () => { it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1; const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
const workspace = await createWorkspace( const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
'20260801000000_gateway',
'20260801000000_game',
futureProtocol
);
await expect(readReleaseManifest(workspace)).rejects.toThrow( await expect(readReleaseManifest(workspace)).rejects.toThrow(
`Release requires controller protocol ${futureProtocol}` `Release requires controller protocol ${futureProtocol}`
@@ -1,3 +1,5 @@
import { generateKeyPairSync } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } }); 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(','); 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 installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
const calls: string[] = []; const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => { 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(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); 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);
});
} }
@@ -14,6 +14,8 @@ const submitting = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const infoMessage = ref(''); const infoMessage = ref('');
const oauthSessionId = ref(''); const oauthSessionId = ref('');
const passwordSetupSessionId = ref('');
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
const email = ref(''); const email = ref('');
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.'; infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
return; return;
} }
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
if (result.status === 'account_recovery') { if (result.status === 'account_recovery') {
accountRecovery.value = result; accountRecovery.value = result;
email.value = result.email; email.value = result.email;
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
await router.replace('/lobby'); await router.replace('/lobby');
return; return;
} }
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
oauthSessionId.value = result.oauthSessionId; oauthSessionId.value = result.oauthSessionId;
email.value = result.email; email.value = result.email;
} catch (error) { } catch (error) {
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
} }
}; };
const setMigratedPassword = async (): Promise<void> => {
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<void> => { const register = async (): Promise<void> => {
errorMessage.value = ''; errorMessage.value = '';
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
@@ -156,7 +200,15 @@ onMounted(() => {
<main id="oauth-container"> <main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1> <h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card"> <section class="oauth-card">
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2> <h2>
{{
accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p> <p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p> <p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인"> <div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
@@ -181,6 +233,46 @@ onMounted(() => {
<RouterLink class="back-link" to="/">취소</RouterLink> <RouterLink class="back-link" to="/">취소</RouterLink>
</div> </div>
</div> </div>
<form
v-else-if="passwordSetupSessionId"
class="password-setup-form"
aria-label=" 비밀번호 설정"
@submit.prevent="setMigratedPassword"
>
<p class="oauth-message">
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 비밀번호를 설정해 주세요.
</p>
<div class="form-row">
<label for="migrated-password-email">카카오 이메일</label>
<input id="migrated-password-email" :value="email" readonly />
</div>
<div class="form-row">
<label for="migrated-password"> 비밀번호</label>
<input
id="migrated-password"
v-model="password"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<div class="form-row">
<label for="migrated-password-confirm">비밀번호 확인</label>
<input
id="migrated-password-confirm"
v-model="confirmPassword"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<button class="register-button" type="submit" :disabled="submitting">
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</form>
<form v-else-if="oauthSessionId" @submit.prevent="register"> <form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row"> <div class="form-row">
<label for="oauth-email">카카오 이메일</label> <label for="oauth-email">카카오 이메일</label>
+80 -37
View File
@@ -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 uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
repeatable. 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 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 that happens to exist in a dump. Tables outside that schema remain only in the
recovery dump. 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 that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump. 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 `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
remaining KakaoTalk ownership-proof interval instead of forcing an immediate 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 `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 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 Every imported 128-hex legacy password is marked `password_reset_required`.
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored The dump contains the per-user salt but not Ref's installation-wide salt, so
value to Argon2id. A test-only account can instead be reset with the CLI and a the dump alone cannot validate the old plaintext password. If the original
mode-0600 password file: `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 ```sh
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \ GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
@@ -50,24 +65,43 @@ The password is never accepted as an argument or printed.
### Game profiles ### Game profiles
| Legacy table | Target | Policy | | Legacy table | Dedicated target | Policy |
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- | | ------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
| `ng_games` | `ng_games` | Preserve completed season metadata | | `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
| `hall` | `hall` | Preserve hall-of-fame rows | | `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner | | `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs | | `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
| `emperior` | `emperior` | Preserve dynasty detail and legacy key | | `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | | `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
| `user_record` | `inheritance_log` | Preserve complete long-lived user record | | `user_record` | `inheritance_log` | Preserve complete long-lived user record |
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | | 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: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 | | `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 | | `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 The source contains legitimate duplicate `(server_id, nation)` old-nation rows
and `(server_id, year, month)` history rows. `source_id` is consequently part of 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 their current-schema archive keys, while the dedicated legacy archive uses
rows use the legacy primary key. This avoids a lossy last-row-wins upsert. `(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 Current-season actor/world/queue/lock/message/market/vote state is explicitly
excluded. In particular, `general`, `city`, `nation`, their turn queues, 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 archive owner from the game session and never accepts an owner ID from the
browser. browser.
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with - `archive.myPastPlays` reads the central legacy archive across profiles and
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional current runtime archives, tags each source, and suppresses a current-schema
`emperior` row. It returns summary fields and a link target for the existing duplicate when the central legacy copy exists.
public dynasty/nation detail. - `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in the session owner in the database predicate. A foreign or missing record uses
the database predicate before returning `data.history`. A foreign or missing the same not-found response.
record uses the same not-found response. - The detail DTO feeds the same `GeneralBasicCard`, battle summary,
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API `LegacyGeneralProgress`, and record panels used by My Page/Battle Center.
normalizes both to a newest-first string array, and the frontend renders plain Missing battle/mastery/log channels show an explicit not-preserved state.
text rather than archived markup. - Legacy `data.history` may be either an array or a `<br>`-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 - Runtime death and unification archival writes the current general
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed `GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
seasons remain compatible with imported rows. 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 ## Cutover procedure
1. Keep the original compressed dumps immutable and restore each source to a 1. Keep the original compressed dumps immutable and restore each source to a
private MariaDB instance. private MariaDB instance.
2. Deploy the gateway and game Prisma migrations to empty staging databases. 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. counts and excluded-table reasons.
4. Compare source counts, malformed JSON checks and duplicate natural-key 4. Compare source counts, malformed JSON checks and duplicate natural-key
counts. Stop on unexplained drift. counts. Stop on unexplained drift.
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then 5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
run the same commands with `--apply`. run the same commands with `--apply`.
6. Repeat each apply. Counts must remain unchanged. 6. Repeat each apply. Counts must remain unchanged; verify the newest
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership, `legacy_archive.import_run` is `COMPLETED` and current Gateway credentials
old-nation/history duplicate preservation, `/past-plays` list/detail access, are unchanged.
foreign-owner denial and the dynasty link. 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 8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
pre-cutover PostgreSQL backup; it does not reverse individual importer pre-cutover PostgreSQL backup; it does not reverse individual importer
upserts. upserts.
+1
View File
@@ -25,6 +25,7 @@ export * from './ranking/types.js';
export * from './ranking/legacyColor.js'; export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js'; export * from './auth/accountIconProjection.js';
export * from './logging/formatLegacyLogHtml.js'; export * from './logging/formatLegacyLogHtml.js';
export * from './legacyArchive/ArchivedGeneralSnapshot.js';
export * from './gateway/profileStatus.js'; export * from './gateway/profileStatus.js';
export * from './game/accessPenalty.js'; export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js'; export * from './http/trpcTransport.js';
@@ -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<string, ArchivedJsonValue | undefined>;
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<ArchivedJsonValue | undefined>): 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<ArchivedJsonValue | undefined>): 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(/<br\s*\/?>/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,
},
},
};
};
@@ -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}$';
+1
View File
@@ -82,6 +82,7 @@ model AppUser {
displayName String @unique @map("display_name") displayName String @unique @map("display_name")
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
passwordSalt String @map("password_salt") passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb")) roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb")) sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type") oauthType OAuthType @default(NONE) @map("oauth_type")
@@ -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");
+3
View File
@@ -535,6 +535,9 @@ importers:
tools/legacy-db-migration: tools/legacy-db-migration:
dependencies: dependencies:
'@sammo-ts/common':
specifier: workspace:*
version: link:../../packages/common
mariadb: mariadb:
specifier: 3.5.3 specifier: 3.5.3
version: 3.5.3 version: 3.5.3
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", "gatewaySchemaHead": "20260817000000_add_password_reset_required",
"gameSchemaHead": "20260816000000_add_read_model_change_journal", "gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
+20 -8
View File
@@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
route invokes it. route invokes it.
The default mode is a read-only dry-run. `--apply` is required before any target 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. PostgreSQL advisory locks prevent two applies for the same target.
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted Gateway writes are transactional. Game archive writes and their completed
run can be repeated. `legacy_archive.import_run` record are transactional. Stable legacy keys make
completed or interrupted runs repeatable.
## Source restore ## 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 After reviewing the JSON counts and excluded-table reasons, add
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`. `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 ### Isolated current-season comparison fixture
`current-season-fixture` is separate from the long-lived archive migration. It `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 comparison, not proof that the two engines consume RNG identically after the
next turn. next turn.
Kakao members retain their OAuth ID, email, and OAuth metadata. Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time. 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 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. 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 Imported 128-hex password hashes are marked for reset. They can be upgraded to
the first successful login when Argon2id after the first successful login only when the DB-external
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api. `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 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 in a dump, such as an old root `config` table, are left in the recovery dump and
+1
View File
@@ -15,6 +15,7 @@
"migrate": "tsx src/cli.ts" "migrate": "tsx src/cli.ts"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*",
"mariadb": "3.5.3", "mariadb": "3.5.3",
"pg": "^8.16.3" "pg": "^8.16.3"
}, },
+13 -3
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { createMariaPool, createPostgresPool } from './db.js'; 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 { migrateGateway } from './gateway.js';
import { hashPasswordForReset } from './password.js'; import { hashPasswordForReset } from './password.js';
import { migrateCurrentSeasonFixture } from './currentSeason.js'; import { migrateCurrentSeasonFixture } from './currentSeason.js';
@@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise<Record<string, unknow
const hashed = await hashPasswordForReset(password); const hashed = await hashPasswordForReset(password);
await pool.query( await pool.query(
`UPDATE "app_user" `UPDATE "app_user"
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP SET "password_hash" = $1,
"password_salt" = $2,
"password_reset_required" = FALSE,
"updated_at" = CURRENT_TIMESTAMP
WHERE "id" = $3`, WHERE "id" = $3`,
[hashed.hash, hashed.salt, existing.rows[0]!.id] [hashed.hash, hashed.salt, existing.rows[0]!.id]
); );
@@ -142,7 +145,11 @@ const run = async (): Promise<void> => {
const migratedAt = new Date(); const migratedAt = new Date();
if (options.command === 'gateway') { if (options.command === 'gateway') {
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL')); 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 { try {
const summary = await migrateGateway(source, target, options.apply, migratedAt); const summary = await migrateGateway(source, target, options.apply, migratedAt);
console.log(JSON.stringify(summary, null, 2)); console.log(JSON.stringify(summary, null, 2));
@@ -156,6 +163,9 @@ const run = async (): Promise<void> => {
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) { 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}`); 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 source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
const target = const target =
options.apply || options.command === 'current-season-fixture' options.apply || options.command === 'current-season-fixture'
+13 -3
View File
@@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => {
return `"${value}"`; 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 createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
export const createPostgresPool = (connectionString: string): pg.Pool => { export const createPostgresPool = (connectionString: string): pg.Pool => {
@@ -94,7 +102,8 @@ export const upsertRows = async (
client: PoolClient, client: PoolClient,
table: string, table: string,
rows: readonly TargetRow[], rows: readonly TargetRow[],
conflictColumns: readonly string[] conflictColumns: readonly string[],
options: { preserveOnConflict?: readonly string[] } = {}
): Promise<void> => { ): Promise<void> => {
if (rows.length === 0) { if (rows.length === 0) {
return; return;
@@ -116,12 +125,13 @@ export const upsertRows = async (
}); });
return `(${placeholders.join(', ')})`; return `(${placeholders.join(', ')})`;
}); });
const preserved = new Set(options.preserveOnConflict ?? []);
const updates = columns const updates = columns
.filter((column) => !conflictColumns.includes(column)) .filter((column) => !conflictColumns.includes(column) && !preserved.has(column))
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`); .map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING'; const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
await client.query( await client.query(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) `INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
VALUES ${tuples.join(', ')} VALUES ${tuples.join(', ')}
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`, ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
values values
+165 -57
View File
@@ -3,6 +3,16 @@ import { createHash } from 'node:crypto';
import type { Pool as MariaPool } from 'mariadb'; import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg'; 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 { import {
paginateSource, paginateSource,
jsonParameter, jsonParameter,
@@ -29,6 +39,12 @@ import {
const batchSize = 250; const batchSize = 250;
interface ArchiveMigrationContext {
profile: LegacyArchiveProfile;
importRunId: string;
sourceFormats: Record<ArchivedGeneralSourceFormat, number>;
}
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue => const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
value === null || value === undefined ? fallback : parseJson(value, context); value === null || value === undefined ? fallback : parseJson(value, context);
@@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => {
return memberNo > 0 ? legacyUserId(memberNo) : null; return memberNo > 0 ? legacyUserId(memberNo) : null;
}; };
const hashYearbook = (row: TargetRow): string => const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string =>
createHash('sha256') createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex');
.update(
JSON.stringify({ const asJsonRecord = (value: JsonValue): Record<string, JsonValue> =>
map: row.map, value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
nations: row.nations,
globalHistory: row.global_history, export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => {
globalAction: row.global_action, const record = asJsonRecord(env);
}) const candidate = record.opentime ?? record.starttime;
) return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context);
.digest('hex'); };
const migrateSimpleTable = async ( const migrateSimpleTable = async (
source: MariaPool, source: MariaPool,
@@ -75,17 +91,24 @@ const migrateSimpleTable = async (
} }
}; };
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> => const migrateHall = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'hall', 'hall',
'id', 'id',
'hall', 'legacy_archive.hall',
['server_id', 'type', 'general_no'], ['source_profile', 'server_id', 'type', 'general_no'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'hall.id'); const sourceId = toNumber(row.id, 'hall.id');
return { return {
source_profile: archive.profile,
legacy_id: sourceId,
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
season: toNumber(row.season, `hall.${sourceId}.season`), season: toNumber(row.season, `hall.${sourceId}.season`),
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`), 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`), value: toFloat(row.value, `hall.${sourceId}.value`),
owner: ownerId(row.owner), owner: ownerId(row.owner),
aux: parseJson(row.aux, `hall.${sourceId}.aux`), aux: parseJson(row.aux, `hall.${sourceId}.aux`),
import_run_id: archive.importRunId,
}; };
}, },
counts counts
); );
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> => const migrateGames = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'ng_games', 'ng_games',
'id', 'id',
'ng_games', 'legacy_archive.game_history',
['server_id'], ['source_profile', 'server_id'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_games.id'); 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 { return {
source_profile: archive.profile,
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`), 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: winner_nation:
row.winner_nation === null row.winner_nation === null
? null ? null
@@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
season: toNumber(row.season, `ng_games.${sourceId}.season`), season: toNumber(row.season, `ng_games.${sourceId}.season`),
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`), scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`), 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 counts
@@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
const migrateOldGenerals = ( const migrateOldGenerals = (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => ): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'ng_old_generals', 'ng_old_generals',
'id', 'id',
'ng_old_generals', 'legacy_archive.general',
['server_id', 'general_no'], ['source_profile', 'server_id', 'general_no'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_old_generals.id'); 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 { return {
source_profile: archive.profile,
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`), general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
legacy_id: sourceId,
owner: ownerId(row.owner), 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`), last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`), 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 counts
@@ -156,41 +203,47 @@ const migrateOldGenerals = (
const migrateOldNations = ( const migrateOldNations = (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => ): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'ng_old_nations', 'ng_old_nations',
'id', 'id',
'ng_old_nations', 'legacy_archive.nation',
['server_id', 'nation', 'source_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_old_nations.id'); const sourceId = toNumber(row.id, 'ng_old_nations.id');
return { return {
source_profile: archive.profile,
legacy_id: sourceId,
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`), nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
source_id: sourceId, data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)),
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`), archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`),
date: toDate(row.date, `ng_old_nations.${sourceId}.date`), import_run_id: archive.importRunId,
}; };
}, },
counts counts
); );
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> => const migrateEmperors = (
source: MariaPool,
target: PoolClient | null,
counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'emperior', 'emperior',
'no', 'no',
'emperior', 'legacy_archive.emperor',
['legacy_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const id = toNumber(row.no, 'emperior.no'); const id = toNumber(row.no, 'emperior.no');
return { const data = {
legacy_id: id,
server_id: toNullableString(row.server_id),
phase: toNullableString(row.phase), phase: toNullableString(row.phase),
nation_count: toNullableString(row.nation_count), nation_count: toNullableString(row.nation_count),
nation_name: toNullableString(row.nation_name), 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`), history: parseNullableJson(row.history, [], `emperior.${id}.history`),
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`), 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 counts
); );
@@ -295,30 +355,35 @@ const migrateUserRecords = (
const migrateYearbook = async ( const migrateYearbook = async (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => { ): Promise<void> => {
await migrateSimpleTable( await migrateSimpleTable(
source, source,
target, target,
'ng_history', 'ng_history',
'no', 'no',
'yearbook_history', 'legacy_archive.yearbook',
['profile_name', 'year', 'month', 'source_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const id = toNumber(row.no, 'ng_history.no'); 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 = { const mapped: TargetRow = {
source_profile: archive.profile,
legacy_id: id,
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`), profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
source_id: id,
year: toNumber(row.year, `ng_history.${id}.year`), year: toNumber(row.year, `ng_history.${id}.year`),
month: toNumber(row.month, `ng_history.${id}.month`), month: toNumber(row.month, `ng_history.${id}.month`),
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`), map: jsonParameter(map),
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`), nations: jsonParameter(nations),
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`), global_history: jsonParameter(globalHistory),
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`), global_action: jsonParameter(globalAction),
hash: '', content_hash: hashYearbook(map, nations, globalHistory, globalAction),
created_at: new Date(0), import_run_id: archive.importRunId,
}; };
mapped.hash = hashYearbook(mapped);
return mapped; return mapped;
}, },
counts, counts,
@@ -388,7 +453,16 @@ export const migrateGame = async (
apply: boolean, apply: boolean,
profile: string profile: string
): Promise<MigrationSummary> => { ): Promise<MigrationSummary> => {
if (!isLegacyArchiveProfile(profile)) {
throw new Error(`Unsupported legacy archive profile: ${profile}`);
}
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
const sourceFormats: Record<ArchivedGeneralSourceFormat, number> = {
'legacy-flat-v0': 0,
'ref-flat-v1': 0,
'core-snapshot-v1': 0,
unknown: 0,
};
const excluded = { const excluded = {
general: 'Current-season actor state is intentionally not transferred.', general: 'Current-season actor state is intentionally not transferred.',
city: 'Current-season world 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.', 'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
}; };
const client = apply && targetPool ? await targetPool.connect() : null; const client = apply && targetPool ? await targetPool.connect() : null;
let importRunId: string | null = null;
try { try {
const run = async (): Promise<void> => { const run = async (archive: ArchiveMigrationContext): Promise<void> => {
await migrateGames(source, client, counts); await migrateGames(source, client, counts, archive);
await migrateHall(source, client, counts); await migrateHall(source, client, counts, archive);
await migrateOldGenerals(source, client, counts); await migrateOldGenerals(source, client, counts, archive);
await migrateOldNations(source, client, counts); await migrateOldNations(source, client, counts, archive);
await migrateEmperors(source, client, counts); await migrateEmperors(source, client, counts, archive);
await migrateInheritanceResults(source, client, counts); await migrateInheritanceResults(source, client, counts);
await migrateUserRecords(source, client, counts); await migrateUserRecords(source, client, counts);
await migrateStorage(source, client, counts); await migrateStorage(source, client, counts);
await migrateYearbook(source, client, counts); await migrateYearbook(source, client, counts, archive);
}; };
if (client) { 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 { } else {
await run(); await run({ profile, importRunId: '0', sourceFormats });
} }
} finally { } finally {
client?.release(); client?.release();
} }
return { command: 'game', apply, counts, excluded }; return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats };
}; };
+94 -14
View File
@@ -24,17 +24,83 @@ export interface MigrationSummary {
apply: boolean; apply: boolean;
counts: Record<string, number>; counts: Record<string, number>;
excluded: Record<string, string>; excluded: Record<string, string>;
importRunId?: string | null;
sourceFormatSummary?: Record<string, number>;
} }
const batchSize = 500; 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<void> => {
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 memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`); const acl = parseJson(row.acl, `member.${memberNo}.acl`);
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`); const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`); const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE'; 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 = { const legacyData: JsonValue = {
memberNo, memberNo,
grade, grade,
@@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
id: legacyUserId(memberNo), id: legacyUserId(memberNo),
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(), login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`), 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_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash),
roles: jsonParameter(mapLegacyRoles(grade, acl)), roles: jsonParameter(mapLegacyRoles(grade, acl)),
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)), sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
oauth_type: oauthType, oauth_type: oauthType,
oauth_id: toNullableString(row.oauth_id), oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo), oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg', 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, third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null, terms_accepted_at: null,
privacy_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: kakao_talk_verified_until:
oauthType === 'KAKAO' oauthType === 'KAKAO' && oauthId
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`) ? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
: null, : null,
kakao_grace_started_at: migratedAt, kakao_grace_started_at: migratedAt,
@@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> =>
const processMembers = async ( const processMembers = async (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
apply: boolean,
migratedAt: Date, migratedAt: Date,
counts: Record<string, number> counts: Record<string, number>
): Promise<void> => { ): Promise<void> => {
@@ -103,7 +171,10 @@ const processMembers = async (
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null); return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
}); });
if (target) { 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; counts.member = (counts.member ?? 0) + mapped.length;
} }
@@ -206,17 +277,26 @@ export const migrateGateway = async (
login_token: login_token:
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.', '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 { try {
const run = async (): Promise<void> => { const run = async (): Promise<void> => {
await processMembers(source, client, migratedAt, counts); await processMembers(source, client, apply, migratedAt, counts);
await processMemberLogs(source, client, counts); await processMemberLogs(source, apply ? client : null, counts);
await processBannedMembers(source, client, counts); await processBannedMembers(source, apply ? client : null, counts);
await processRootKeyValues(source, client, counts); await processRootKeyValues(source, apply ? client : null, counts);
await processSystem(source, client, counts); await processSystem(source, apply ? client : null, counts);
}; };
if (client) { if (client && apply) {
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run); 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 { } else {
await run(); await run();
} }
@@ -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<ArchivedJsonValue> =>
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: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
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);
});
});
+24
View File
@@ -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')
);
});
@@ -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": "<C>●</>첫 기록<br><Y>●</>둘째 기록<br>",
"recent_war": "2020-01-02 03:04:05",
"ip": "192.0.2.1",
"lastconnect": "2020-01-02 03:04:05",
"refresh": 10
}
@@ -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": ["<C>●</>최신 기록", "<Y>●</>이전 기록"],
"recent_war": null,
"aux": "",
"ip": "198.51.100.1",
"lastconnect": "2026-01-02 03:04:05"
}
+146
View File
@@ -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<br>second<br>' }),
},
],
} satisfies Record<string, Array<Record<string, unknown>>>;
const sourcePool = (): MariaPool => {
const seen = new Set<string>();
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'
);
});
});
@@ -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<string, unknown> = {}) => ({
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"');
});
});