feat: add date-fns library for improved date handling and validation

This commit is contained in:
2025-12-30 04:05:12 +00:00
parent 7b719f00de
commit e4c57e2467
6 changed files with 32 additions and 20 deletions
+1
View File
@@ -21,6 +21,7 @@
"@sammo-ts/infra": "workspace:*", "@sammo-ts/infra": "workspace:*",
"@sammo-ts/logic": "workspace:*", "@sammo-ts/logic": "workspace:*",
"@trpc/server": "^11.4.4", "@trpc/server": "^11.4.4",
"date-fns": "^4.1.0",
"fastify": "^5.3.3", "fastify": "^5.3.3",
"redis": "^5.10.0", "redis": "^5.10.0",
"zod": "^4.2.1" "zod": "^4.2.1"
+4 -6
View File
@@ -1,5 +1,6 @@
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken.js'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken.js';
import { isAfter, isValid, parseISO } from 'date-fns';
import type { FlushStore } from './flushStore.js'; import type { FlushStore } from './flushStore.js';
@@ -8,11 +9,8 @@ export interface GameTokenVerifier {
} }
const parseDate = (value: string): Date | null => { const parseDate = (value: string): Date | null => {
const parsed = new Date(value); const parsed = parseISO(value);
if (Number.isNaN(parsed.getTime())) { return isValid(parsed) ? parsed : null;
return null;
}
return parsed;
}; };
export const createGameTokenVerifier = (options: { export const createGameTokenVerifier = (options: {
@@ -34,7 +32,7 @@ export const createGameTokenVerifier = (options: {
if (!expiresAt || !issuedAt) { if (!expiresAt || !issuedAt) {
return null; return null;
} }
if (Date.now() > expiresAt.getTime()) { if (isAfter(new Date(), expiresAt)) {
return null; return null;
} }
const flushedAt = options.flushStore.getFlushedAt(payload.user.id); const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
+1
View File
@@ -21,6 +21,7 @@
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/infra": "workspace:*", "@sammo-ts/infra": "workspace:*",
"@trpc/server": "^11.4.4", "@trpc/server": "^11.4.4",
"date-fns": "^4.1.0",
"fastify": "^5.3.3", "fastify": "^5.3.3",
"redis": "^5.10.0", "redis": "^5.10.0",
"zod": "^4.2.1" "zod": "^4.2.1"
+13 -12
View File
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { addHours, addSeconds, isAfter, isValid, parseISO } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { import {
@@ -18,11 +19,8 @@ const zProfile = z.string().min(1).max(64);
const zOAuthMode = z.enum(['login', 'change_pw']); const zOAuthMode = z.enum(['login', 'change_pw']);
const parseDate = (value: string): Date | null => { const parseDate = (value: string): Date | null => {
const parsed = new Date(value); const parsed = parseISO(value);
if (Number.isNaN(parsed.getTime())) { return isValid(parsed) ? parsed : null;
return null;
}
return parsed;
}; };
export const appRouter = router({ export const appRouter = router({
@@ -67,11 +65,13 @@ export const appRouter = router({
}); });
} }
const token = await ctx.kakaoClient.exchangeCode(input.code); const token = await ctx.kakaoClient.exchangeCode(input.code);
const accessTokenValidUntil = new Date( const tokenIssuedAt = new Date();
Date.now() + token.accessTokenExpiresIn * 1000 const accessTokenValidUntil = addSeconds(
tokenIssuedAt,
token.accessTokenExpiresIn
).toISOString(); ).toISOString();
const refreshTokenValidUntil = token.refreshTokenExpiresIn const refreshTokenValidUntil = token.refreshTokenExpiresIn
? new Date(Date.now() + token.refreshTokenExpiresIn * 1000).toISOString() ? addSeconds(tokenIssuedAt, token.refreshTokenExpiresIn).toISOString()
: undefined; : undefined;
const signupResult = await ctx.kakaoClient.signup(token.accessToken); const signupResult = await ctx.kakaoClient.signup(token.accessToken);
@@ -117,7 +117,8 @@ export const appRouter = router({
const nextPasswordChange = existing.oauthInfo?.nextPasswordChange const nextPasswordChange = existing.oauthInfo?.nextPasswordChange
? parseDate(existing.oauthInfo.nextPasswordChange) ? parseDate(existing.oauthInfo.nextPasswordChange)
: null; : null;
if (nextPasswordChange && Date.now() < nextPasswordChange.getTime()) { const now = new Date();
if (nextPasswordChange && isAfter(nextPasswordChange, now)) {
throw new TRPCError({ throw new TRPCError({
code: 'TOO_MANY_REQUESTS', code: 'TOO_MANY_REQUESTS',
message: '비밀번호 초기화는 잠시 후 다시 시도해주세요.', message: '비밀번호 초기화는 잠시 후 다시 시도해주세요.',
@@ -129,7 +130,7 @@ export const appRouter = router({
`임시 비밀번호는 ${tempPassword} 입니다. 로그인 후 바로 다른 비밀번호로 변경해주세요.`, `임시 비밀번호는 ${tempPassword} 입니다. 로그인 후 바로 다른 비밀번호로 변경해주세요.`,
ctx.publicBaseUrl ctx.publicBaseUrl
); );
const nextChange = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); const nextChange = addHours(now, 4).toISOString();
await ctx.users.updatePassword(existing.id, tempPassword); await ctx.users.updatePassword(existing.id, tempPassword);
await ctx.users.updateOAuthInfo(existing.id, { await ctx.users.updateOAuthInfo(existing.id, {
...oauthInfo, ...oauthInfo,
@@ -321,7 +322,7 @@ export const appRouter = router({
version: 1, version: 1,
profile: gameSession.profile, profile: gameSession.profile,
issuedAt: now.toISOString(), issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 1000 * ctx.gameSessionTtlSeconds).toISOString(), expiresAt: addSeconds(now, ctx.gameSessionTtlSeconds).toISOString(),
sessionId: gameSession.gameToken, sessionId: gameSession.gameToken,
user: { user: {
id: gameSession.userId, id: gameSession.userId,
@@ -366,7 +367,7 @@ export const appRouter = router({
return null; return null;
} }
const expiresAt = parseDate(payload.expiresAt); const expiresAt = parseDate(payload.expiresAt);
if (!expiresAt || Date.now() > expiresAt.getTime()) { if (!expiresAt || isAfter(new Date(), expiresAt)) {
return null; return null;
} }
return { return {
+1 -1
View File
@@ -14,6 +14,6 @@
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"tsdown": "^0.18.3", "tsdown": "^0.18.3",
"typescript": "^5.5.4" "typescript": "^5.9.3"
} }
} }
+12 -1
View File
@@ -15,7 +15,7 @@ importers:
specifier: ^0.18.3 specifier: ^0.18.3
version: 0.18.3(typescript@5.9.3) version: 0.18.3(typescript@5.9.3)
typescript: typescript:
specifier: ^5.5.4 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
app/game-api: app/game-api:
@@ -35,6 +35,9 @@ importers:
'@trpc/server': '@trpc/server':
specifier: ^11.4.4 specifier: ^11.4.4
version: 11.8.1(typescript@5.9.3) version: 11.8.1(typescript@5.9.3)
date-fns:
specifier: ^4.1.0
version: 4.1.0
fastify: fastify:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.6.2 version: 5.6.2
@@ -102,6 +105,9 @@ importers:
'@trpc/server': '@trpc/server':
specifier: ^11.4.4 specifier: ^11.4.4
version: 11.8.1(typescript@5.9.3) version: 11.8.1(typescript@5.9.3)
date-fns:
specifier: ^4.1.0
version: 4.1.0
fastify: fastify:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.6.2 version: 5.6.2
@@ -878,6 +884,9 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -2173,6 +2182,8 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
date-fns@4.1.0: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3