refactor: API 엔드포인트 SRP 및 DRY 리팩토링 (#62) - #63
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Summary by CodeRabbitRelease Notes
Walkthrough공통 API 핸들러, 중앙 에러 계층, Zod 기반 검증 스키마를 도입하여 40개 이상 라우트의 중복 제거. OAuth origin 처리 통합과 캘린더 어댑터 클래스화로 외부 연동과 데이터 변환 계약을 표준화합니다. ChangesAPI 핸들러 및 외부 연동 통합
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Why: 40개 이상 라우트의 핸들러 시그니처 변경과 공통 래퍼 도입으로 이해해야 할 제어 흐름이 많으며, 에러 계층 중앙화와 어댑터 클래스화는 기존 코드와의 호환성을 확인해야 합니다. How: (1) Code snippet - createApiHandler 핵심 흐름: export function createApiHandler<TBodySchema extends z.ZodTypeAny>(
options: ApiHandlerOptions<TBodySchema>,
handler: (context: ApiContext<z.infer<TBodySchema>>) => Promise<NextResponse>
) {
return async (req: NextRequest, routeContext?: any) => {
const params = routeContext?.params ? await routeContext.params : {};
const session = options.requireAuth ? (await getSession()) || null : await getSession() || null;
if (options.requireAuth && !session) {
return NextResponse.json({ success: false, message: "인증이 필요합니다." }, { status: 401 });
}
let body: any = undefined;
if (options.bodySchema) {
try {
body = await req.json();
} catch {
return NextResponse.json({ success: false, message: "요청 형식이 올바르지 않습니다." }, { status: 400 });
}
const parsed = options.bodySchema.safeParse(body);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return NextResponse.json(
{ success: false, message: issue.message, field: issue.path[0] },
{ status: 422 }
);
}
body = parsed.data;
}
try {
return await handler({ req, session, body, params });
} catch (error) {
return apiErrorHandler(error);
}
};
}Possibly related PRs
Suggested labels
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 45
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
e2e/host-flow.spec.ts (1)
16-37: 🧹 Nitpick | 🔵 Trivial | 💤 Low value폼 입력 순서 변경 의도가 불명확함.
기존과 달리
phone → nickname → password → email순서로 입력합니다. WebKit 자동완성 버그와 관련이 있는지, 아니면 다른 이유인지 주석으로 명시해야 디버깅 시 혼란을 방지합니다.📝 의도 명시 예시
// 1. 회원가입 진행 await page.goto("/signup"); + // WebKit 자동완성 버그 우회: 이메일을 마지막에 입력해 브라우저의 자동완성 트리거를 회피함 const phoneInput = page.locator("`#phoneNumber`"); await phoneInput.waitFor({ state: "visible", timeout: 10000 });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/host-flow.spec.ts` around lines 16 - 37, The form input order was changed to phoneInput → nicknameInput → pwInput → pwConfirmInput → emailInput but the intent is not documented; update the test (e2e/host-flow.spec.ts) by adding a short comment above the sequence referencing why the order was altered (e.g., "Workaround for WebKit autocomplete/auto-fill issue" or "matching client-side validation order") and include mention of any related bug link or ticket ID; ensure the comment names the relevant locators (phoneInput, nicknameInput, pwInput, pwConfirmInput, emailInput) so future readers understand the reason for the nonstandard input order.src/app/api/auth/reset-password/complete/route.ts (1)
7-91:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
createApiHandler미전환으로 DRY/SRP 목표 부분 달성.이 라우트는
validatePassword만 도입하고createApiHandler기반 구조로 전환하지 않아, 수동req.json()파싱(lines 8-16), 에러 응답 구성(lines 12-15, 20-27),Authorization헤더 파싱(lines 31-37)이 여전히 중복 코드로 남음. PR 목표("API 엔드포인트 SRP 및 DRY 리팩토링")와 불일치하며,signup/login등 다른 인증 라우트와 구조 일관성이 깨짐.Why: 중복 제거가 불완전하면 향후 에러 응답 형식 변경 시 이 라우트만 누락되거나, 세션 검증 로직 추가 시 별도 수정이 필요해 유지보수 부담이 증가함.
How:forgot-password.schema.ts와 유사하게reset-password-complete.schema.ts를 정의하고createApiHandler({ bodySchema, requireAuth: false })로 전환. Authorization 헤더 파싱은 핸들러 내부에서 처리하거나,createApiHandler에customAuth옵션 추가.♻️ 제안 구조 (스키마 추가 + 핸들러 전환)
1단계: 스키마 정의 (
src/features/auth/reset-password-complete.schema.ts)import { z } from "zod"; import { passwordSchema } from "./password.schema"; export const resetPasswordCompleteSchema = z.object({ password: passwordSchema, });2단계: 라우트 전환
-import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { createClient } from "`@/lib/supabase/server`"; -import { validatePassword } from "`@/features/auth/password.schema`"; +import { createApiHandler } from "`@/lib/api-handler`"; +import { resetPasswordCompleteSchema } from "`@/features/auth/reset-password-complete.schema`"; export const dynamic = "force-dynamic"; -export async function POST(req: NextRequest) { - let body: { password?: string }; - try { - body = await req.json(); - } catch { - return NextResponse.json( - { success: false, message: "요청 형식이 올바르지 않습니다." }, - { status: 400 }, - ); - } - - const { password } = body; - if (!password || !validatePassword(password)) { - return NextResponse.json( - { - success: false, - message: - "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상이어야 합니다.", - }, - { status: 400 }, - ); - } - - // 1. Authorization 헤더로부터 Bearer 토큰 추출 - const authHeader = req.headers.get("Authorization"); +export const POST = createApiHandler( + { bodySchema: resetPasswordCompleteSchema, requireAuth: false }, + async ({ body, req }) => { + const { password } = body; + + const authHeader = req.headers.get("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return NextResponse.json( - { success: false, message: "인증 정보가 누락되었습니다." }, - { status: 401 }, - ); - } - const token = authHeader.split(" ")[1]; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { success: false, message: "인증 정보가 누락되었습니다." }, + { status: 401 }, + ); + } + const token = authHeader.split(" ")[1]; - try { - // 2. Supabase Auth를 통해 토큰으로 세션을 설정하고 비밀번호 변경 const supabase = await createClient(); // ... (기존 로직 유지) return NextResponse.json(...); - } catch (err) { - console.error("[auth.reset-password.complete] 서버 오류:", err); - return NextResponse.json( - { success: false, message: "서버 내부 오류가 발생했습니다." }, - { status: 500 }, - ); - } -} + }, +);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/auth/reset-password/complete/route.ts` around lines 7 - 91, The route is still using manual req.json(), validatePassword checks and Authorization parsing instead of the shared createApiHandler pattern; define a zod schema (e.g. resetPasswordCompleteSchema) mirroring forgot-password.schema (password: passwordSchema), then convert this POST handler to use createApiHandler({ bodySchema: resetPasswordCompleteSchema, requireAuth: false }) and move Authorization header handling into the handler callback (or implement createApiHandler's customAuth option) so you remove the manual JSON parsing/duplicate error responses and align with other auth routes (keep using createClient/supabase.auth calls inside the new handler).src/app/api/auth/forgot-password/route.ts (1)
24-29:⚠️ Potential issue | 🟠 Major | ⚡ Quick win계정 존재 여부를 응답으로 노출하지 마세요.
Why: 이 엔드포인트는 인증 없이 호출되는데, 현재는 미가입 이메일에만 404를 내려서 가입 여부를 외부에서 대입 확인할 수 있습니다. 비밀번호 재설정 플로우에서 가장 흔한 계정 열거 취약점입니다.
How: 미가입 주소도 같은 200 응답과 같은 메시지로 처리하고, 내부에서만 조용히 종료하세요.최소 수정 예시
+ const genericMessage = + "입력한 이메일로 안내가 전송되었는지 확인해 주세요."; + - if (profileError || !profile) { + if (profileError) throw profileError; + if (!profile) { return NextResponse.json( - { success: false, message: "가입되지 않은 이메일 주소입니다." }, - { status: 404 }, + { success: true, message: genericMessage }, + { status: 200 }, ); } @@ return NextResponse.json( { success: true, - message: "비밀번호 재설정 메일이 성공적으로 전송되었습니다.", + message: genericMessage, }, { status: 200 }, );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/auth/forgot-password/route.ts` around lines 24 - 29, Replace the distinct 404 branch that reveals account existence: when checking profile/profileError in src/app/api/auth/forgot-password/route.ts (the if (profileError || !profile) branch using NextResponse.json), return the same 200 JSON response and message as the successful path instead of a 404; ensure no differing status or message is sent for non-existent accounts and that any downstream email-sending logic is skipped silently for missing profiles.src/lib/auth/naver.ts (1)
44-48:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win에러 메시지가 실제 로직과 불일치
Line 46과 Line 77의 에러 메시지가 "NAVER_REDIRECT_URI 환경변수가 설정되지 않았습니다"라고 하지만, 실제로는 Line 42-43과 Line 75-76에서
origin기반으로 자동 생성되므로 환경변수가 없어도 동작합니다.Why: 부정확한 에러 메시지는 디버깅을 어렵게 하고 사용자에게 혼란을 줍니다.
How: 실제 검증 로직에 맞게 에러 메시지 수정
제안 수정
const redirectUri = process.env.NAVER_REDIRECT_URI || `${base}/api/auth/naver/callback`; - if (!clientId || !redirectUri) { + if (!clientId) { throw new Error( - "NAVER_CLIENT_ID 또는 NAVER_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + "NAVER_CLIENT_ID 환경변수가 설정되지 않았습니다.", ); }Also applies to: 77-79
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/auth/naver.ts` around lines 44 - 48, The error text is misleading: update the checks around clientId, redirectUri and origin so the thrown messages reflect the actual logic — require NAVER_CLIENT_ID as a fatal error and if redirectUri cannot be derived from origin, throw a message stating NAVER_REDIRECT_URI could not be determined from origin (instead of claiming the env var is missing). Change both occurrences that reference clientId/redirectUri (variables clientId, redirectUri, origin) so the messages match the validation behavior.src/app/api/everytime/timetable/route.ts (1)
19-40:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftcreateApiHandler 사용 목적과 불일치 - 불완전한 DRY 적용
createApiHandler를 빈 설정{}로 호출하고 내부에서 여전히req.json(),req.formData()를 수동 파싱하고 있습니다. 이는 PR 목표인 "세션 확인·바디 파싱·Zod 검증 중복 제거"와 일치하지 않으며, 같은 PR의 다른 API 라우트들(iCloud events/create, events/query)과 패턴이 불일치합니다.Why:
- 일관성: 같은 리팩토링 범위 내에서 서로 다른 패턴을 사용하면 코드베이스 복잡도가 증가하고 유지보수 비용이 높아집니다.
- DRY 원칙: 공통 래퍼를 도입했지만 실제로는 중복 제거 효과가 없습니다.
How: 다음 중 하나를 선택하세요.
옵션 1 (권장): JSON 경로에 대해서만 bodySchema 적용
제안 수정
// JSON 요청용 스키마 정의 const TimetableUrlSchema = z.object({ url: z.string().url("유효하지 않은 URL입니다.") .refine(url => { const parsed = new URL(url); return parsed.protocol === 'https:' && parsed.hostname === 'everytime.kr'; }, { message: "everytime.kr의 https URL만 허용됩니다." }), days: z.array(z.enum(["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"])).optional(), }); // JSON 경로를 별도 엔드포인트로 분리 export const POST = createApiHandler( { bodySchema: TimetableUrlSchema }, async ({ body }) => { const timetable = await fetchTimetableFromUrl(body.url); const freeSlots = timetableToFreeSlots(timetable, { candidateDays: body.days }); try { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (user) { await supabase.auth.updateUser({ data: { everytime_url: body.url, everytime_slots: freeSlots }, }); } } catch (err) { console.error("[everytime] 유저 메타데이터 저장 실패:", err); } return NextResponse.json({ timetable, freeSlots }); } ); // 파일 업로드는 별도 엔드포인트로 분리 (예: POST /api/everytime/timetable/upload)옵션 2: 이 API만 createApiHandler를 사용하지 않고 원래 패턴 유지
제안 수정
-import { createApiHandler } from "`@/lib/api-handler`"; -export const POST = createApiHandler({}, async ({ req }) => { +export async function POST(req: NextRequest) { const contentType = req.headers.get("content-type") ?? ""; // ... 기존 로직 그대로 -}); +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/everytime/timetable/route.ts` around lines 19 - 40, The current POST uses createApiHandler({}) but still manually parses req and branches to handleUrlRequest/handleFileRequest, violating the DRY refactor; define a JSON body Zod schema (e.g., TimetableUrlSchema) and refactor the JSON path to use createApiHandler({ bodySchema: TimetableUrlSchema }, async ({ body }) => { ... }) to handle URL fetch, timetableToFreeSlots, and optional supabase save, and move multipart/form-data handling into a separate upload endpoint (or keep this route without createApiHandler if you prefer option 2); update/remove usages of handleUrlRequest/handleFileRequest accordingly so this file no longer calls req.json()/req.formData() directly.src/app/api/google/events/query/route.ts (1)
21-47: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win쿼리 파라미터 검증이 수동으로 이루어져 DRY 위반
POST 엔드포인트는
bodySchema로 Zod 검증을 위임하는 반면, GET 엔드포인트는 쿼리 파라미터를 수동으로 검증합니다.Why: 동일한 API 계층에서 검증 방식이 혼재하면 유지보수 시 일관성이 떨어지고, 수동 검증 코드는 중복되기 쉽습니다.
How: Zod 스키마로 쿼리 파라미터를 정의하고,
createApiHandler에querySchema옵션을 추가하거나, 핸들러 내부에서 스키마 검증을 추상화하세요.♻️ Zod 기반 쿼리 파라미터 검증 제안
+const QueryEventsSchema = z.object({ + calendarId: z.string().min(1, "calendarId는 필수입니다."), + startDate: z.string().datetime({ message: "startDate는 ISO 8601 형식이어야 합니다." }), + endDate: z.string().datetime({ message: "endDate는 ISO 8601 형식이어야 합니다." }), +}).refine((data) => new Date(data.startDate) < new Date(data.endDate), { + message: "endDate는 startDate보다 나중이어야 합니다.", + path: ["endDate"], +}); + export const GET = createApiHandler({}, async ({ req }) => { const tokens = await getValidTokens(); if (!tokens) { return NextResponse.json( { error: "Google 계정이 연결되지 않았습니다." }, { status: 401 }, ); } - const calendarId = req.nextUrl.searchParams.get("calendarId"); - const startDate = req.nextUrl.searchParams.get("startDate"); - const endDate = req.nextUrl.searchParams.get("endDate"); - - if (!calendarId || !startDate || !endDate) { + const params = Object.fromEntries(req.nextUrl.searchParams); + const result = QueryEventsSchema.safeParse(params); + if (!result.success) { return NextResponse.json( - { error: "calendarId, startDate, endDate 파라미터가 필요합니다." }, + { error: result.error.errors[0].message }, { status: 400 }, ); } + const { calendarId, startDate, endDate } = result.data; const start = new Date(startDate); const end = new Date(endDate); - - if (isNaN(start.getTime()) || isNaN(end.getTime())) { - return NextResponse.json( - { error: "날짜 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요." }, - { status: 400 }, - ); - } - - if (start >= end) { - return NextResponse.json( - { error: "endDate는 startDate보다 나중이어야 합니다." }, - { status: 400 }, - ); - } try {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/google/events/query/route.ts` around lines 21 - 47, Replace the manual GET query validation with Zod-based validation to match the POST behavior: define a Zod schema for calendarId, startDate, endDate (reusing the existing bodySchema shape if appropriate) and validate req.nextUrl.searchParams at the top of the GET handler (route.ts) or pass it into createApiHandler via a new querySchema option; on validation failure return the same NextResponse.json error structure and on success parse the validated start/end into Dates (and keep the start<end check if desired). Ensure you reference the handler using route.ts and the existing bodySchema/createApiHandler symbols so the querySchema approach is consistent and DRY.src/app/api/google/callback/route.ts (1)
14-49:⚠️ Potential issue | 🟠 Major | ⚡ Quick winOAuth 콜백에서 state 파라미터 검증 누락
콜백 핸들러가
error,code파라미터는 검증하지만state파라미터 검증을 수행하지 않습니다. 이는 CSRF 공격을 허용합니다.Why: 공격자가 피해자를 악의적으로 조작된 OAuth 콜백 URL로 유도하면, 피해자의 세션에 공격자의 Google 계정이 연결될 수 있습니다.
How: auth route에서 생성한 state 값을 쿠키/세션에서 조회하여 콜백 파라미터의 state와 비교 검증하세요.
🔐 state 검증 로직 추가 제안
export async function GET(req: NextRequest) { + const state = req.nextUrl.searchParams.get("state"); const code = req.nextUrl.searchParams.get("code"); const error = req.nextUrl.searchParams.get("error"); + // state 검증 + const cookieStore = await cookies(); + const savedState = cookieStore.get("oauth_state")?.value; + if (!state || state !== savedState) { + return NextResponse.json( + { error: "잘못된 state 파라미터입니다." }, + { status: 400 }, + ); + } + cookieStore.delete("oauth_state"); + if (error) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/google/callback/route.ts` around lines 14 - 49, The GET handler currently validates error and code but omits CSRF protection via the OAuth state; update the GET function to retrieve the state query param and compare it against the state value you saved when initiating the auth flow (e.g., from the same cookie/session key used by your auth route), and if missing or mismatched return a 400 error and do not call exchangeCodeForTokens or saveTokensToCookie; specifically, in the GET handler before calling exchangeCodeForTokens, read the stored state (cookie/session), compare to req.nextUrl.searchParams.get("state"), and reject on mismatch, then clear the stored state after successful verification to prevent reuse.src/app/api/google/calendars/route.ts (1)
8-38:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGoogle 캘린더 라우트 3개(
calendars,events/create,events/query)의 인증 방식이disconnect와 불일치
src/app/api/google/calendars/route.ts,src/app/api/google/events/create/route.ts,src/app/api/google/events/query/route.ts모두getValidTokens()쿠키 조회만 사용하는 반면,src/app/api/google/disconnect/route.ts는requireAuth: true로 Supabase 세션을 요구합니다.공유된 root cause는 Google 관련 엔드포인트들이 서로 다른 인증 메커니즘을 사용한다는 점입니다. 쿠키만으로는 멀티테넌시 환경에서 사용자를 식별할 수 없으며, 한 브라우저에서 여러 사용자가 Google 계정을 전환할 때 혼선이 발생할 수 있습니다. 모든 Google 라우트가
requireAuth: true를 사용하고 DB에서profile_id기준으로 토큰을 조회하도록 통일하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/google/calendars/route.ts` around lines 8 - 38, The Google routes currently call createApiHandler({}, ...) and use getValidTokens() (cookie-only) which is inconsistent with disconnect(route) that uses requireAuth: true and profile_id; change each Google route (e.g., the GET handler in calendars route, and the handlers in events/create and events/query) to call createApiHandler({ requireAuth: true }, async ({ session }) => { ... }), remove the cookie-only getValidTokens() usage, and instead load the tokens from the database keyed by session.user.id (profile_id) before calling listCalendars / createEvent / queryEvents; keep existing error handling and use the retrieved tokens.accessToken.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.env.example:
- Line 45: Reorder the environment variable keys in .env.example so they are
alphabetically sorted within each section; specifically move APPLE_REDIRECT_URI
(and the key at the other noted spot) into the correct alphabetical position
among the surrounding keys to satisfy dotenv-linter. Edit the block containing
APPLE_REDIRECT_URI to place its entry in proper A–Z order and ensure any other
section mentioned (the line 51 key) is similarly reordered.
In `@e2e/host-flow.spec.ts`:
- Around line 39-54: Extract the repeated "check inputValue and fill if
different" pattern into a reusable helper: create e2e/utils/form-helpers.ts
exporting ensureInputValue(input: Locator, expectedValue: string) that reads
input.inputValue() and calls input.fill(expectedValue) only if different, then
replace the five duplicated blocks in host-flow.spec.ts (phoneInput,
nicknameInput, emailInput, pwInput, pwConfirmInput) with calls to
ensureInputValue; ensure other E2E tests (e.g., participant-flow) import and
reuse this helper for consistency.
In `@prisma/schema.prisma`:
- Around line 6-10: The Prisma datasource in schema.prisma is hard-coded to
provider = "sqlite", which conflicts with CI/deployment that supplies a
PostgreSQL DATABASE_URL; update the datasource block (datasource db) to use the
postgresql provider and include url, directUrl and schemas entries to match
CI/production (e.g., provider = "postgresql", url = env("DATABASE_URL"),
directUrl = env("DIRECT_URL"), schemas = ["public","auth"]), and if you need
SQLite for local experiments create a separate Prisma schema or script instead
of changing datasource db.
In `@src/app/api/auth/login/route.ts`:
- Line 16: The handler currently uses an untyped destructured parameter `async
({ body }) =>` which weakens IDE support; update the route handler to explicitly
type the incoming request/body using z.infer<typeof loginSchema> (or a named
type alias) so the callback signature is strongly typed and aligns with
createApiHandler's generics; locate the anonymous async callback in route.ts
(the function passed into createApiHandler) and change its parameter to include
the explicit type for `body` referencing loginSchema to restore autocomplete,
refactoring safety, and stronger type checks.
- Line 33: Currently the code rethrows the raw Supabase error via "throw
profileError", which bypasses the centralized error layer and can leak internal
DB details; catch or replace the thrown object where "profileError" is handled
and wrap it in a known application error (e.g., throw new UnauthorizedError(...)
or throw new MoimError({...})) so "createApiHandler" can normalize the response;
include a sanitized clientMessage and preserve original error details only in
the error's internal/details field or logger to aid debugging while preventing
raw Supabase objects from reaching clients.
In `@src/app/api/auth/logout/route.ts`:
- Around line 9-16: The current logout handler calls supabase.auth.signOut() but
always returns NextResponse.json(..., {status:200}) even when signOut returns an
error; update the route (the code around supabase.auth.signOut in route.ts) to
check the returned error and, if present, return an error response (5xx) with
the error details instead of a 200 success, otherwise return the 200 success
only after signOut completed; ensure you use the signOut result to decide
between NextResponse.json({ success: true, message: ...}, { status: 200 }) and a
server error response (e.g., NextResponse.json({ success: false, message:
error.message || String(error) }, { status: 500 })) so callers can retry or
surface the failure.
In `@src/app/api/auth/signup/route.ts`:
- Line 16: The handler callback currently uses an untyped destructured parameter
`async ({ body }) => { ... }`, weakening IDE support and type safety; update the
parameter to explicitly type `body` (e.g., using `z.infer<typeof signupSchema>`
or a named type) so the async handler passed to `createApiHandler` has a
concrete request-body type; locate the async handler in the `route.ts` file (the
function passed into `createApiHandler`) and replace the implicit destructured
param with a typed signature referencing `signupSchema` (or an exported alias)
to restore precise inference and editor completion.
- Around line 85-96: The current code returns NextResponse.json directly on
Supabase signup errors, bypassing the centralized MoimError handling; instead,
catch the error from supabase.auth.signUp and map/wrap it into an appropriate
MoimError subclass (e.g., UnauthorizedError or a new
DuplicateResourceError/MoimConflictError for 409 cases) preserving original
message/details/code, then throw that MoimError so createApiHandler can
log/metric and generate the consistent JSON response; remove the direct
NextResponse.json return in the signup error path and ensure any status-to-code
logic (duplicate vs. bad request) is encoded in the MoimError you throw.
In `@src/app/api/google/auth/route.ts`:
- Around line 10-14: The auth route's GET currently calls
buildAuthUrl(undefined, origin) without an anti-CSRF state and doesn't set a
cookie, and the callback GET lacks state verification; fix by generating a
cryptographically random state (e.g., via randomBytes) in the auth GET, pass it
into buildAuthUrl(state, origin), and set it as an httpOnly, secure, sameSite
cookie (name like "oauth_state") on the NextResponse.redirect; then in the
callback GET, read the incoming query state and compare it to
cookies().get("oauth_state")?.value and immediately abort (401) if missing or
mismatched before calling exchangeCodeForTokens or continuing the flow.
In `@src/app/api/icloud/connect/route.ts`:
- Around line 29-86: The route currently catches CalDAVError locally and
bypasses the centralized error pipeline; remove the try/catch in the route
handler (the block around discoverCalDAV/saveConnection) so errors propagate to
createApiHandler, and instead add a CalDAVError branch inside apiErrorHandler
that maps statusCode===401 to the 401 Apple-auth failure JSON and other
CalDAVError cases to the 502 iCloud-connect JSON; keep any non-sensitive logging
(use maskEmail) in the central handler as appropriate so CalDAV-specific
responses are handled centrally while the route only performs discovery/save via
discoverCalDAV and saveConnection.
- Around line 22-28: The POST handler in the iCloud connect route creates the
icloud_connection cookie without session verification; update the
createApiHandler call in src/app/api/icloud/connect/route.ts (the POST route) to
require authentication by adding requireAuth: true to its options so
saveConnection cannot set the cookie without a valid session; also review
related handlers that call getConnectionAuth (icloud/calendars, icloud/events/*,
icloud/disconnect) and, if needed, add CSRF/Origin-Referer checks or requireAuth
there as well to ensure the icloud_connection cookie is tied to a verified user
session.
In `@src/app/api/icloud/disconnect/route.ts`:
- Around line 7-10: The POST handler for iCloud disconnect uses createApiHandler
without auth, allowing clearConnection() to delete the icloud_connection cookie
without verifying session; update the POST export in
src/app/api/icloud/disconnect/route.ts to call createApiHandler with {
requireAuth: true } (matching the google/disconnect pattern) so getSession() is
enforced before clearConnection(), and apply the same change to the iCloud
connect handler (createApiHandler({ requireAuth: true, bodySchema: ConnectSchema
}, ...)); also ensure CSRF protections are enabled in the handler options or
middleware as appropriate.
In `@src/app/api/icloud/events/create/route.ts`:
- Around line 83-98: The catch block handling CalDAVError should guard against
an undefined statusCode to avoid runtime errors: in the route handler's catch,
change the existing instanceof CalDAVError checks to also verify typeof
err.statusCode === 'number' (or use optional chaining with an explicit type
guard) before comparing values; then branch (e.g., switch on err.statusCode) to
return the 401 and 409/412 responses as before. Ensure you reference CalDAVError
and its statusCode property in the guard so undefined statusCode won't be
compared.
In `@src/app/api/icloud/events/query/route.ts`:
- Around line 70-75: The current check assumes CalDAVError.statusCode exists;
change the conditional to defensively verify the property before comparing
(mirror the pattern used in icloud/events/create). Update the clause that
references CalDAVError and statusCode to something like: check err instanceof
CalDAVError && typeof err.statusCode === 'number' && err.statusCode === 401 (or
use optional chaining err?.statusCode === 401), then return the same
NextResponse.json(...) for the 401 case so the code is type-safe and consistent.
In `@src/app/api/schedules/`[id]/route.ts:
- Around line 113-116: The hostToken variable is checked using
body.hostToken.trim() but the untrimmed body.hostToken is still used when
calling confirmSchedule, causing tokens with surrounding whitespace to fail; fix
by computing a trimmedHostToken (e.g., const trimmedHostToken = typeof
body.hostToken === "string" ? body.hostToken.trim() : "") and use that
trimmedHostToken as the primary token (falling back to
req.cookies.get(getHostTokenCookieName(id))?.value if trimmedHostToken is empty)
so that confirmSchedule() receives the normalized token.
- Around line 126-135: In the catch block that currently returns
NextResponse.json({ error: message }, { status }), only convert known domain
errors ("schedule not found" -> 404, "invalid host token" -> 403) to HTTP
responses and re-throw any other exceptions so the global
createApiHandler/common error middleware can handle them (and produce 500s,
logs, retries). Concretely, inside the catch around the route handler, check
error instanceof Error and if message equals "schedule not found" or "invalid
host token" return the mapped NextResponse; otherwise throw the original error
(do not swallow or convert it to 400).
In `@src/lib/__tests__/api-handler.test.ts`:
- Around line 11-151: Add tests verifying that createApiHandler maps MoimError
subclasses to their HTTP status and response fields: write tests that call
createApiHandler with handlers that throw new UnauthorizedError() and new
ForbiddenError() (and optionally other MoimError subclasses), then assert the
response status matches statusCode (401/403), body.code equals the error code
(e.g., "UNAUTHORIZED"/"FORBIDDEN"), and body.message equals the error's
clientMessage; locate the test cases alongside existing tests in
src/lib/__tests__/api-handler.test.ts and mirror the existing pattern (construct
NextRequest, invoke handler, await res.json()) to validate the MoimError
handling path in createApiHandler.
In `@src/lib/__tests__/errors.test.ts`:
- Around line 14-87: Add defensive unit tests for MoimError hierarchy: add
it-blocks that instantiate MoimError (and subclasses like ExternalServiceError,
CalDAVError, EverytimeError/EverytimeAuthError) with details omitted/undefined
to assert details defaults to undefined or {}, omit clientMessage to assert
default clientMessage is used, construct errors with invalid statusCode values
(e.g., -1, 999) to assert class behavior/normalization or that the value is
preserved, and create ExternalServiceError with empty or whitespace service name
to assert the generated code and clientMessage fallback; reference constructors
MoimError, ExternalServiceError, CalDAVError, EverytimeError/EverytimeAuthError,
UnauthorizedError, ForbiddenError to locate targets.
In `@src/lib/api-handler.ts`:
- Around line 111-117: The apiErrorHandler function currently uses
console.error; replace this with a structured logging call (e.g., pino/winston
or a project logger) that logs a generated error/trace ID, timestamp, request
context (path, method, session or user id if available), and the full stack
trace or serialized error object, then return the same NextResponse; ensure the
handler (apiErrorHandler) either accepts or can access request/context values to
attach them, and include the error ID in the returned response body or headers
so callers can correlate logs with incidents.
- Around line 85-88: apiErrorHandler currently returns a generic 500 for all
errors; update it to detect if the thrown error is an instance of MoimError (and
its subclasses like UnauthorizedError, ForbiddenError) and use the error's
statusCode, code, and clientMessage to build the HTTP response instead of always
500. Specifically, inside apiErrorHandler (the catch path referenced in
api-handler.ts) check `if (err instanceof MoimError)` then set the response
status to err.statusCode, include err.clientMessage (and err.code where
applicable) in the JSON body; fall back to a 500 with a generic message for
non-MoimError exceptions. Ensure the function signature and return shape remain
compatible with callers.
- Around line 47-54: Replace the hardcoded 401 NextResponse in the
authentication check with throwing the centralized UnauthorizedError so
api-level errors are handled by apiErrorHandler; specifically, inside the block
that calls getSession() and checks options.requireAuth, throw new
UnauthorizedError() (or the exported UnauthorizedError) instead of returning
NextResponse.json, ensuring apiErrorHandler/MoimError logic will map statusCode
and clientMessage consistently for responses.
- Around line 36-45: The code initializes params with unsafe assertion (params =
{} as TParams) which can hide missing dynamic route params at runtime; update
the logic in src/lib/api-handler.ts (symbols: routeContext, params, TParams) to
either 1) surface an explicit error when routeContext is absent for handlers
that require dynamic params (throw a descriptive Error instead of defaulting to
{}), or 2) change the handler signature to accept Partial<TParams> and update
callers/handlers to validate required fields (e.g., check for params.id) before
use; pick one approach and remove the type assertion so runtime code cannot
access undefined fields silently.
In `@src/lib/auth/__tests__/naver.test.ts`:
- Around line 56-84: Add tests for the failure branches of getNaverToken: add
cases that assert behavior when mockFetch resolves with ok: false, when the
response JSON contains an error (data.error), and when the response lacks
access_token (no data.access_token). For each case, call getNaverToken with the
same parameters as the success test and mock fetch to return the corresponding
failure payloads or throw a network error, then assert that the function
throws/rejects with the expected error or message (use the same rejection shape
your implementation throws). Reference getNaverToken and
mockFetch.mockResolvedValueOnce/mockRejectedValueOnce to locate where to add
these tests. Ensure assertions cover !res.ok, data.error, and missing
access_token branches.
In `@src/lib/auth/naver.ts`:
- Around line 40-43: The origin parameter is currently used without validation
creating an open redirect risk; add a whitelist-based validator (e.g., a new
validateOrigin(origin?: string): string) and call it from getNaverAuthUrl(state,
origin?) and getNaverToken(code, state, origin?) to produce the base used for
redirectUri instead of using origin directly; ensure validateOrigin normalizes
to protocol+host, checks against allowedOrigins (including NEXT_PUBLIC_BASE_URL
and localhost entries), returns a safe default when origin is absent, and throws
on invalid/unallowed origins.
In `@src/lib/calendar/adapter.ts`:
- Around line 27-40: In mapToCalendarEvent add defensive validation for the
template-method returns: call getExternalId, getStartAt and getEndAt and verify
externalId is a non-empty string (throw an Error identifying this.source and the
offending item), verify startAt and endAt (if present) are Date objects and not
Invalid Date (use isNaN(startAt.getTime()) check) and throw a clear exception if
invalid, and ensure if endAt exists that startAt <= endAt (throw with context
mentioning this.source, externalId and the date values); keep these checks at
the top of mapToCalendarEvent so all subclasses get invariant enforcement.
- Line 32: Clarify the template-method contract for title fallback: update the
getTitle(item) JSDoc to include an explicit `@returns` that states whether
implementations should return a possibly-empty string (and let the base class
apply the "(제목 없음)" fallback) or return a non-empty title including the
fallback; OR make concrete adapters (Google/iCloud) consistently return summary
|| "(제목 없음)" instead of summary || "" so the behavior is uniform; reference
getTitle and the Google/iCloud adapter methods (where summary is used) when
applying the change.
In `@src/lib/calendar/adapters/__tests__/manual.test.ts`:
- Around line 47-59: 테스트가 정상 경로만 검증하므로 manualAdapter.toCalendarEvents의 경계값·에러
케이스를 추가해 회귀를 막으세요: weekStart가 Invalid Date일 때 (new Date("invalid"))는 명확한 예외를
던지는지, slots가 null/undefined일 때 안전한 폴백(또는 예외)을 반환/던지는지, startHour > endHour인 비정상
슬롯은 에러를 던지는지, DST 전환 구간(예: 2026-03-08)에서 duration이 올바른지(건너뛰는 시간 반영), 자정(00:00)과
23:59 경계 시간이 올바르게 처리되는지 각각의 테스트를 manual.test.ts에 추가하고 expect(...).toThrow() 또는
명시된 폴백 동작을 검증하도록 수정하세요; 필요한 경우 toCalendarEvents 내부 검증 로직(weekStart 유효성 검사, slots
null 체크, startHour<=endHour 검증)을 보강해 테스트가 일관되게 통과하도록 만드세요.
In `@src/lib/calendar/adapters/google.ts`:
- Around line 19-29: The getStartAt and getEndAt methods currently assert
event.start.dateTime/event.end.dateTime as string which can produce Invalid Date
if dateTime is missing; update both methods (getStartAt, getEndAt) to explicitly
check for dateTime presence before constructing new Date (e.g., if
event.start.date then parseAllDay(event.start.date) else if event.start.dateTime
then new Date(event.start.dateTime) else throw a descriptive error), and do the
same for event.end, using GoogleEvent and parseAllDay as referenced symbols to
locate the logic.
- Around line 43-46: The parseAllDay function currently builds a UTC midnight
(Date.UTC) which misaligns all-day events vs local dates; change
parseAllDay(yyyymmdd) to construct a local midnight Date (use the local Date
constructor with y,m-1,d) so comparisons like isSameDay and grid rendering use
the user's local date; if your runtime requires explicit TZ handling instead,
update callers to pass event.start.timeZone and compute the midnight for that
zone rather than using Date.UTC.
In `@src/lib/calendar/adapters/manual.ts`:
- Around line 59-63: The addHours method uses setHours(getHours() + hours) which
breaks across DST transitions; change it to perform linear UTC-based arithmetic
by creating a new Date from base.getTime() + hours * 3600000 (or replace with a
reliable utility like date-fns addHours) and return that new Date so hour
additions are consistent across DST; update the addHours function accordingly
(reference: addHours).
- Around line 26-28: The current getExternalId method in ManualAdapter uses the
array index (item.index) which makes externalId unstable when slot order
changes; update getExternalId (in src/lib/calendar/adapters/manual.ts) to derive
the ID only from the slot contents (e.g.,
`${item.slot.day}-${item.slot.startHour}-${item.slot.endHour}`) or compute a
stable hash of the slot object instead of including item.index so the same
logical slot yields the same externalId regardless of array position.
In `@src/lib/calendar/adapters/photo.ts`:
- Around line 15-21: The getExternalId stub in photo adapter currently throws
and breaks the BaseCalendarAdapter.mapToCalendarEvent call chain; replace the
throw in getExternalId with a safe temporary implementation (for example return
item.startAt.getTime().toString()) so that toCalendarEvents and
mapToCalendarEvent can run during incremental development, or alternatively
remove getExternalId and ensure toCalendarEvents is the single place that
throws; update the photo adapter's getExternalId method (referenced by
getExternalId and BaseCalendarAdapter.mapToCalendarEvent and toCalendarEvents)
accordingly.
In `@src/lib/errors.ts`:
- Around line 4-6: The class MoimError currently defaults code="INTERNAL_ERROR"
and clientMessage="서버 내부 오류가 발생했습니다." which can conflict with a custom
constructor message; update MoimError so its default error identity is neutral
(e.g., code="" or "UNKNOWN_ERROR") and/or remove defaults so callers/subclasses
must provide explicit values, and update the constructor signature and JSDoc
accordingly; ensure references to the fields code, statusCode, and clientMessage
in other modules still compile and add clear guidance in MoimError's JSDoc to
require subclasses or callsites to set a meaningful code and clientMessage.
- Around line 4-7: The statusCode field (public readonly statusCode: number =
500) lacks 100–599 validation allowing invalid HTTP codes to propagate; update
the class constructor (where statusCode is set) to validate the incoming
statusCode and if it's not an integer in the 100..599 range, set it to the safe
default 500, and ensure any use of the property (e.g., when building responses
with clientMessage and details) relies on this sanitized value; reference the
statusCode property and the class constructor in your change.
In `@src/lib/google/__tests__/auth.test.ts`:
- Around line 67-77: Test mutates process.env directly inside the test case
"GOOGLE_CALENDAR_REDIRECT_URI 환경변수가 있으면 우선 사용한다" (calls delete
process.env.GOOGLE_CALENDAR_REDIRECT_URI) which can leak state on failure;
remove that inline deletion and instead add a beforeEach (or afterEach) hook in
this test file that clears process.env.GOOGLE_CALENDAR_REDIRECT_URI before each
test runs to guarantee isolation, leaving the test to only set the env var, call
buildAuthUrl(), and assert the redirect_uri.
In `@src/lib/google/auth.ts`:
- Around line 37-44: getRedirectUri currently uses the external origin parameter
directly; add a whitelist check to avoid Host header injection by introducing an
environment allowlist (e.g. GOOGLE_ALLOWED_ORIGINS as a comma-separated list)
and validate the origin argument against that list inside getRedirectUri; if
origin is missing or not in the allowlist, fall back to
process.env.NEXT_PUBLIC_BASE_URL or the default "http://localhost:4000" and
optionally log/throw a warning so only trusted origins are used when building
the `${base}/api/google/callback` redirect URI.
In `@src/lib/supabase/client.ts`:
- Around line 20-29: The cookie parsing for mockUid, mockEmail, and mockNickname
is unsafe because .split("=")[1] drops any additional '=' characters in the
value; update the extraction logic used for the consts mockUid, mockEmail, and
mockNickname to preserve everything after the first '=' (for example, split on
'=' then rejoin parts.slice(1).join('=') or use a regex /(?:^|; )key=([^;]*)/ to
capture the full value), and apply the same fix to all cookie parses in this
block so multi '=' values like "test=user@example.com" are returned intact.
- Around line 90-119: The mock currently returns {data: null, error: null} for
any table other than "profiles", causing silent failures; update the fromMock
implementation (functions: fromMock, select, eq, maybeSingle, single) to throw
or return an explicit error when table !== "profiles" (include the table name
and mockUid/mockNickname context in the error message) and apply this change to
both maybeSingle and single so unsupported-table access surfaces a clear,
descriptive error instead of silent null data.
- Around line 121-124: The return uses a blanket `as any` which loses type
safety; instead import or reference SupabaseClient and create a narrow mock type
(e.g. type MockSupabaseClient = Pick<SupabaseClient, "auth" | "from">) and
return the object as `authMock`/`fromMock` cast to that type (or `as unknown as
MockSupabaseClient`) so the shape is checked at compile time; ensure you
reference the existing symbols `authMock` and `fromMock` in the cast.
- Around line 16-18: The mock-environment detection in isMockEnv is too broad
because url.includes("example") can match real projects; change the check to
either match only exact known dummy hostnames or a precise regex against the
full URL (e.g., only allow exact hostnames like "example-project.supabase.co" or
a strict pattern), and/or add an explicit environment flag (e.g.,
SUPABASE_USE_MOCK or similar) to force mock mode; update references to the
isMockEnv variable and url usage in this module accordingly so only true dummy
URLs or an explicit env flag enable mock behavior.
In `@src/lib/supabase/server.ts`:
- Around line 100-138: The auth mock (verifyOtp, updateUser, setSession) always
returns success, preventing E2E tests from exercising failure flows; modify
these functions to detect a failure-injection flag (from a cookie or environment
variable) and return appropriate error responses (e.g., error objects and null
data) when the flag indicates scenarios such as expired token or invalid
credentials; keep the default behavior unchanged when no flag is present and
document the flag values/semantics so tests can set the cookie/env to simulate
each failure case.
- Around line 192-194: The dynamic Prisma import inside async execute (the block
where this.table === "profiles" and you call await import("`@/lib/prisma`")) lacks
error handling; wrap that import in a try-catch, catch any import failure, log
or attach the error, and return a Supabase-style error response consistent with
other branches (e.g., an object with an error/message and appropriate status) so
the function does not throw an uncaught exception; update the execute method's
profiles branch to use this try-catch around the import and return the
standardized error object on failure.
- Around line 309-315: maybeSingle()와 single()가 현재 단순히 this를 반환해 실제 동작 차이를 반영하지
않습니다; 변경: make maybeSingle()와 single() set an internal mode flag (e.g.,
this._expect = 'maybeSingle' or 'single') on the query builder, then update
execute() to inspect this._expect and enforce Supabase semantics: for
'maybeSingle' return { data: null, error: null } when no rows; for 'single'
return an error when no rows (and return an error when multiple rows are
returned in both modes if applicable). Update any result/error construction
paths in execute() to produce the appropriate Postgrest-like error objects for
the 'single' case.
- Around line 200-217: The current manual per-field if-blocks that copy
this.updateData into the local data object (see this.updateData and data in
src/lib/supabase/server.ts) should be replaced with a declarative mapping table:
define a map from incoming snake_case keys to target camelCase property names
(and indicate which fields require Date conversion, e.g., terms_agreed_at and
privacy_agreed_at), then iterate the map to copy and transform values only when
the source key exists (converting to Date or null where needed). Update the code
around the existing block that builds data so it uses this mapping loop instead
of the repeated if statements.
- Around line 220-235: The mock upsert currently falls back to using an external
id (lookupId = this.updateData.id || id) which diverges from Supabase semantics
where upsert conflict key is the primary key (id) in the payload; update the
mock in the upsert path (the this.isUpsert branch that calls prisma.user.upsert)
to require payload id only: if this.updateData?.id is missing throw an error
like "profiles upsert mock: payload에 id(기본키)가 필요합니다." and set lookupId =
this.updateData.id (do not fall back to the external id or eq value) so the mock
matches Supabase behavior.
---
Outside diff comments:
In `@e2e/host-flow.spec.ts`:
- Around line 16-37: The form input order was changed to phoneInput →
nicknameInput → pwInput → pwConfirmInput → emailInput but the intent is not
documented; update the test (e2e/host-flow.spec.ts) by adding a short comment
above the sequence referencing why the order was altered (e.g., "Workaround for
WebKit autocomplete/auto-fill issue" or "matching client-side validation order")
and include mention of any related bug link or ticket ID; ensure the comment
names the relevant locators (phoneInput, nicknameInput, pwInput, pwConfirmInput,
emailInput) so future readers understand the reason for the nonstandard input
order.
In `@src/app/api/auth/forgot-password/route.ts`:
- Around line 24-29: Replace the distinct 404 branch that reveals account
existence: when checking profile/profileError in
src/app/api/auth/forgot-password/route.ts (the if (profileError || !profile)
branch using NextResponse.json), return the same 200 JSON response and message
as the successful path instead of a 404; ensure no differing status or message
is sent for non-existent accounts and that any downstream email-sending logic is
skipped silently for missing profiles.
In `@src/app/api/auth/reset-password/complete/route.ts`:
- Around line 7-91: The route is still using manual req.json(), validatePassword
checks and Authorization parsing instead of the shared createApiHandler pattern;
define a zod schema (e.g. resetPasswordCompleteSchema) mirroring
forgot-password.schema (password: passwordSchema), then convert this POST
handler to use createApiHandler({ bodySchema: resetPasswordCompleteSchema,
requireAuth: false }) and move Authorization header handling into the handler
callback (or implement createApiHandler's customAuth option) so you remove the
manual JSON parsing/duplicate error responses and align with other auth routes
(keep using createClient/supabase.auth calls inside the new handler).
In `@src/app/api/everytime/timetable/route.ts`:
- Around line 19-40: The current POST uses createApiHandler({}) but still
manually parses req and branches to handleUrlRequest/handleFileRequest,
violating the DRY refactor; define a JSON body Zod schema (e.g.,
TimetableUrlSchema) and refactor the JSON path to use createApiHandler({
bodySchema: TimetableUrlSchema }, async ({ body }) => { ... }) to handle URL
fetch, timetableToFreeSlots, and optional supabase save, and move
multipart/form-data handling into a separate upload endpoint (or keep this route
without createApiHandler if you prefer option 2); update/remove usages of
handleUrlRequest/handleFileRequest accordingly so this file no longer calls
req.json()/req.formData() directly.
In `@src/app/api/google/calendars/route.ts`:
- Around line 8-38: The Google routes currently call createApiHandler({}, ...)
and use getValidTokens() (cookie-only) which is inconsistent with
disconnect(route) that uses requireAuth: true and profile_id; change each Google
route (e.g., the GET handler in calendars route, and the handlers in
events/create and events/query) to call createApiHandler({ requireAuth: true },
async ({ session }) => { ... }), remove the cookie-only getValidTokens() usage,
and instead load the tokens from the database keyed by session.user.id
(profile_id) before calling listCalendars / createEvent / queryEvents; keep
existing error handling and use the retrieved tokens.accessToken.
In `@src/app/api/google/callback/route.ts`:
- Around line 14-49: The GET handler currently validates error and code but
omits CSRF protection via the OAuth state; update the GET function to retrieve
the state query param and compare it against the state value you saved when
initiating the auth flow (e.g., from the same cookie/session key used by your
auth route), and if missing or mismatched return a 400 error and do not call
exchangeCodeForTokens or saveTokensToCookie; specifically, in the GET handler
before calling exchangeCodeForTokens, read the stored state (cookie/session),
compare to req.nextUrl.searchParams.get("state"), and reject on mismatch, then
clear the stored state after successful verification to prevent reuse.
In `@src/app/api/google/events/query/route.ts`:
- Around line 21-47: Replace the manual GET query validation with Zod-based
validation to match the POST behavior: define a Zod schema for calendarId,
startDate, endDate (reusing the existing bodySchema shape if appropriate) and
validate req.nextUrl.searchParams at the top of the GET handler (route.ts) or
pass it into createApiHandler via a new querySchema option; on validation
failure return the same NextResponse.json error structure and on success parse
the validated start/end into Dates (and keep the start<end check if desired).
Ensure you reference the handler using route.ts and the existing
bodySchema/createApiHandler symbols so the querySchema approach is consistent
and DRY.
In `@src/lib/auth/naver.ts`:
- Around line 44-48: The error text is misleading: update the checks around
clientId, redirectUri and origin so the thrown messages reflect the actual logic
— require NAVER_CLIENT_ID as a fatal error and if redirectUri cannot be derived
from origin, throw a message stating NAVER_REDIRECT_URI could not be determined
from origin (instead of claiming the env var is missing). Change both
occurrences that reference clientId/redirectUri (variables clientId,
redirectUri, origin) so the messages match the validation behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: d480fbc2-f731-4286-870f-e744fea85d88
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.jsonpackage.jsonis excluded by!**/*.json
📒 Files selected for processing (56)
.env.examplee2e/host-flow.spec.tsprisma/schema.prismascripts/ensure-sqlite-schema.mjssrc/app/(auth)/reset-password/complete/page.tsxsrc/app/(auth)/signup/page.tsxsrc/app/api/auth/forgot-password/route.tssrc/app/api/auth/login/route.tssrc/app/api/auth/logout/route.tssrc/app/api/auth/me/route.tssrc/app/api/auth/naver/callback/route.tssrc/app/api/auth/naver/login/route.tssrc/app/api/auth/profile/complete/route.tssrc/app/api/auth/reset-password/complete/route.tssrc/app/api/auth/signup/route.tssrc/app/api/everytime/timetable/route.tssrc/app/api/google/auth/route.tssrc/app/api/google/calendars/route.tssrc/app/api/google/callback/route.tssrc/app/api/google/disconnect/route.tssrc/app/api/google/events/create/route.tssrc/app/api/google/events/query/route.tssrc/app/api/icloud/calendars/route.tssrc/app/api/icloud/connect/route.tssrc/app/api/icloud/disconnect/route.tssrc/app/api/icloud/events/create/route.tssrc/app/api/icloud/events/query/route.tssrc/app/api/schedules/[id]/route.tssrc/app/api/schedules/route.tssrc/features/auth/__tests__/password.schema.test.tssrc/features/auth/forgot-password.schema.tssrc/features/auth/password.schema.tssrc/features/auth/signup.schema.tssrc/features/schedules/schedule.schema.tssrc/lib/__tests__/api-handler.test.tssrc/lib/__tests__/errors.test.tssrc/lib/api-handler.tssrc/lib/auth/__tests__/naver.test.tssrc/lib/auth/naver.tssrc/lib/auth/session.tssrc/lib/caldav/client.tssrc/lib/calendar/adapter.tssrc/lib/calendar/adapters/__tests__/manual.test.tssrc/lib/calendar/adapters/google.tssrc/lib/calendar/adapters/icloud.tssrc/lib/calendar/adapters/manual.tssrc/lib/calendar/adapters/photo.tssrc/lib/errors.tssrc/lib/everytime/auth.tssrc/lib/everytime/timetable.tssrc/lib/everytime/url-scraper.tssrc/lib/google/__tests__/auth.test.tssrc/lib/google/auth.tssrc/lib/supabase/client.tssrc/lib/supabase/env.tssrc/lib/supabase/server.ts
| APPLE_KEY_ID=your_apple_key_id_here | ||
| APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" | ||
| APPLE_REDIRECT_URI=http://localhost:3000/api/auth/apple/callback | ||
| APPLE_REDIRECT_URI=http://localhost:4000/api/auth/apple/callback |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
환경변수 키 정렬 순서 개선 고려
dotenv-linter가 키 정렬 순서 불일치를 지적하고 있습니다. 알파벳 순서대로 정렬하면 가독성이 개선됩니다.
Why: 환경변수 파일의 일관된 정렬은 병합 충돌을 줄이고 변경사항 추적을 용이하게 합니다.
How: 각 섹션 내에서 알파벳 순으로 재정렬
제안 수정
Line 45 관련:
APPLE_CLIENT_ID=your_apple_services_id_here
-APPLE_TEAM_ID=your_apple_team_id_here
APPLE_KEY_ID=your_apple_key_id_here
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
APPLE_REDIRECT_URI=http://localhost:4000/api/auth/apple/callback
+APPLE_TEAM_ID=your_apple_team_id_hereLine 51 관련:
+NAVER_CALENDAR_REDIRECT_URI=http://localhost:4000/api/naver/callback
NAVER_CLIENT_ID=your_naver_client_id_here
NAVER_CLIENT_SECRET=your_naver_client_secret_here
NAVER_REDIRECT_URI=http://localhost:4000/api/auth/naver/callback
-NAVER_CALENDAR_REDIRECT_URI=http://localhost:4000/api/naver/callbackAlso applies to: 51-51
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 45-45: [UnorderedKey] The APPLE_REDIRECT_URI key should go before the APPLE_TEAM_ID key
(UnorderedKey)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.env.example at line 45, Reorder the environment variable keys in
.env.example so they are alphabetically sorted within each section; specifically
move APPLE_REDIRECT_URI (and the key at the other noted spot) into the correct
alphabetical position among the surrounding keys to satisfy dotenv-linter. Edit
the block containing APPLE_REDIRECT_URI to place its entry in proper A–Z order
and ensure any other section mentioned (the line 51 key) is similarly reordered.
Source: Linters/SAST tools
| // WebKit 자동완성 버그 우회: 폼 제출 직전에 비워진 필드들을 검사하고 재기입함 | ||
| if ((await phoneInput.inputValue()) !== testPhone) { | ||
| await phoneInput.fill(testPhone); | ||
| } | ||
| if ((await nicknameInput.inputValue()) !== testNickname) { | ||
| await nicknameInput.fill(testNickname); | ||
| } | ||
| if ((await emailInput.inputValue()) !== testEmail) { | ||
| await emailInput.fill(testEmail); | ||
| } | ||
| if ((await pwInput.inputValue()) !== "Test1234!") { | ||
| await pwInput.fill("Test1234!"); | ||
| } | ||
| if ((await pwConfirmInput.inputValue()) !== "Test1234!") { | ||
| await pwConfirmInput.fill("Test1234!"); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
WebKit 자동완성 우회 로직이 5번 중복됨.
inputValue() !== 기대값 체크 후 재입력 패턴이 동일하게 반복됩니다. 헬퍼 함수로 추출하고, 다른 E2E 테스트(participant-flow 등)에서도 재사용 가능하도록 공통 유틸리티로 분리해야 합니다.
♻️ 헬퍼 함수 추출
e2e/utils/form-helpers.ts 생성:
import { Locator } from "`@playwright/test`";
/** WebKit 자동완성으로 비워진 필드를 재입력한다 */
export async function ensureInputValue(
input: Locator,
expectedValue: string
) {
const currentValue = await input.inputValue();
if (currentValue !== expectedValue) {
await input.fill(expectedValue);
}
}테스트에서 사용:
+ import { ensureInputValue } from "./utils/form-helpers";
+
// WebKit 자동완성 버그 우회
- if ((await phoneInput.inputValue()) !== testPhone) {
- await phoneInput.fill(testPhone);
- }
+ await ensureInputValue(phoneInput, testPhone);
+ await ensureInputValue(nicknameInput, testNickname);
+ await ensureInputValue(emailInput, testEmail);
+ await ensureInputValue(pwInput, "Test1234!");
+ await ensureInputValue(pwConfirmInput, "Test1234!");🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@e2e/host-flow.spec.ts` around lines 39 - 54, Extract the repeated "check
inputValue and fill if different" pattern into a reusable helper: create
e2e/utils/form-helpers.ts exporting ensureInputValue(input: Locator,
expectedValue: string) that reads input.inputValue() and calls
input.fill(expectedValue) only if different, then replace the five duplicated
blocks in host-flow.spec.ts (phoneInput, nicknameInput, emailInput, pwInput,
pwConfirmInput) with calls to ensureInputValue; ensure other E2E tests (e.g.,
participant-flow) import and reuse this helper for consistency.
| { | ||
| bodySchema: loginSchema, | ||
| }, | ||
| async ({ body }) => { |
There was a problem hiding this comment.
핸들러 콜백 파라미터 타입 미명시로 IDE 지원 약화.
async ({ body }) 구조 분해에 타입 주석이 없어 TypeScript 추론에만 의존함. createApiHandler의 제네릭 시그니처가 복잡하거나 변경되면 타입 안전성이 깨질 수 있으며, IDE 자동완성과 리팩토링 도구의 정확도가 떨어짐.
Why: 명시적 타입은 코드 가독성을 높이고, 타입 오류를 호출 지점이 아닌 정의 지점에서 조기 발견하게 함.
How: body의 타입을 z.infer<typeof loginSchema> 또는 별도 타입으로 명시.
📝 제안 수정
+import type { z } from "zod";
+
+type LoginBody = z.infer<typeof loginSchema>;
+
export const POST = createApiHandler(
{
bodySchema: loginSchema,
},
- async ({ body }) => {
+ async ({ body }: { body: LoginBody }) => {
const { loginId, password } = body;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/login/route.ts` at line 16, The handler currently uses an
untyped destructured parameter `async ({ body }) =>` which weakens IDE support;
update the route handler to explicitly type the incoming request/body using
z.infer<typeof loginSchema> (or a named type alias) so the callback signature is
strongly typed and aligns with createApiHandler's generics; locate the anonymous
async callback in route.ts (the function passed into createApiHandler) and
change its parameter to include the explicit type for `body` referencing
loginSchema to restore autocomplete, refactoring safety, and stronger type
checks.
| .maybeSingle(); | ||
| // DB/RLS 오류를 401(인증 실패)로 위장하지 않도록 분리해 처리한다. | ||
|
|
||
| if (profileError) throw profileError; |
There was a problem hiding this comment.
raw Supabase 에러 throw로 에러 계층 우회 및 응답 불일치 위험.
throw profileError로 Supabase PostgrestError 객체를 그대로 던지면, createApiHandler가 이를 MoimError로 인식하지 못해 에러 응답 구조(code, clientMessage, details)가 깨지거나, 최악의 경우 내부 구현 세부사항(DB 스키마, 쿼리 구조)이 클라이언트에 노출될 수 있음.
Why: 중앙 에러 계층을 우회하면 일관된 에러 핸들링이 불가능하고, Supabase 에러의 message가 기술적 세부사항을 포함할 경우 보안 취약점이 됨.
How: profileError를 catch하고 UnauthorizedError 또는 MoimError로 래핑해 throw.
🛡️ 제안 수정
+import { UnauthorizedError } from "`@/lib/errors`";
+
const { data: profile, error: profileError } = await admin
.from("profiles")
.select("email")
.eq("nickname", loginId)
.maybeSingle();
- if (profileError) throw profileError;
+ if (profileError) {
+ console.error("[login] profile 조회 실패:", profileError);
+ throw new UnauthorizedError("사용자 정보 조회에 실패했습니다.");
+ }
email = profile?.email ?? null;또는 더 구체적으로:
+import { MoimError } from "`@/lib/errors`";
+
- if (profileError) throw profileError;
+ if (profileError) {
+ throw new MoimError(
+ `Profile lookup failed: ${profileError.message}`,
+ "PROFILE_LOOKUP_FAILED",
+ 500,
+ "사용자 정보 조회에 실패했습니다.",
+ { code: profileError.code }
+ );
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/login/route.ts` at line 33, Currently the code rethrows the
raw Supabase error via "throw profileError", which bypasses the centralized
error layer and can leak internal DB details; catch or replace the thrown object
where "profileError" is handled and wrap it in a known application error (e.g.,
throw new UnauthorizedError(...) or throw new MoimError({...})) so
"createApiHandler" can normalize the response; include a sanitized clientMessage
and preserve original error details only in the error's internal/details field
or logger to aid debugging while preventing raw Supabase objects from reaching
clients.
| verifyOtp: async ({ | ||
| token_hash, | ||
| type, | ||
| }: { | ||
| token_hash: string; | ||
| type: string; | ||
| }) => { | ||
| return { | ||
| data: { | ||
| user: { | ||
| id: mockUid || `e2e_naver_uid_${Date.now()}`, | ||
| email: mockEmail || "naver_user@example.com", | ||
| user_metadata: { nickname: mockNickname }, | ||
| }, | ||
| session: { access_token: "mock_jwt_token" }, | ||
| }, | ||
| error: null, | ||
| }; | ||
| }, | ||
| updateUser: async ({ data }: { data: any }) => { | ||
| return { | ||
| data: { | ||
| user: { | ||
| id: mockUid, | ||
| email: mockEmail, | ||
| user_metadata: { ...data, nickname: mockNickname }, | ||
| }, | ||
| }, | ||
| error: null, | ||
| }; | ||
| }, | ||
| setSession: async () => { | ||
| return { | ||
| data: { | ||
| session: { access_token: "mock_jwt_token" }, | ||
| }, | ||
| error: null, | ||
| }; | ||
| }, |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff
Auth mock이 성공 케이스만 커버함.
verifyOtp, updateUser, setSession 모두 항상 성공 응답을 반환합니다. E2E 테스트에서 실패 케이스(만료된 토큰, 잘못된 비밀번호 등)를 검증할 수 없습니다. 쿠키나 환경변수로 실패 시나리오를 주입할 수 있도록 확장해야 합니다.
💡 실패 시나리오 주입 예시
verifyOtp: async ({ token_hash, type }) => {
+ const shouldFail = cookieStore.get("e2e_mock_verify_fail")?.value === "true";
+ if (shouldFail) {
+ return { data: { user: null, session: null }, error: new Error("Invalid token") };
+ }
return {
data: {
user: { id: mockUid || `e2e_naver_uid_${Date.now()}`, ... },
session: { access_token: "mock_jwt_token" },
},
error: null,
};
},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 100 - 138, The auth mock (verifyOtp,
updateUser, setSession) always returns success, preventing E2E tests from
exercising failure flows; modify these functions to detect a failure-injection
flag (from a cookie or environment variable) and return appropriate error
responses (e.g., error objects and null data) when the flag indicates scenarios
such as expired token or invalid credentials; keep the default behavior
unchanged when no flag is present and document the flag values/semantics so
tests can set the cookie/env to simulate each failure case.
| async execute() { | ||
| if (this.table === "profiles") { | ||
| const { prisma } = await import("@/lib/prisma"); |
There was a problem hiding this comment.
Prisma 동적 import 실패 처리 누락.
await import("@/lib/prisma")가 실패하면(Prisma 미설치, 경로 오류 등) 함수 전체가 uncaught exception으로 크래시됩니다. try-catch로 감싸고 Supabase 형태의 에러 응답을 반환해야 합니다.
🛡️ Prisma import 에러 처리
async execute() {
if (this.table === "profiles") {
+ try {
const { prisma } = await import("`@/lib/prisma`");
const id = this.eqFilters.id;
// ... 나머지 로직
+ } catch (error) {
+ return {
+ data: null,
+ error: new Error(`Prisma import failed: ${error}`),
+ };
+ }
}
return {
data: null,
error: new Error(`MockTable ${this.table} not implemented`),
};
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 192 - 194, The dynamic Prisma import
inside async execute (the block where this.table === "profiles" and you call
await import("`@/lib/prisma`")) lacks error handling; wrap that import in a
try-catch, catch any import failure, log or attach the error, and return a
Supabase-style error response consistent with other branches (e.g., an object
with an error/message and appropriate status) so the function does not throw an
uncaught exception; update the execute method's profiles branch to use this
try-catch around the import and return the standardized error object on failure.
| if (this.updateData) { | ||
| const data: any = {}; | ||
| if (this.updateData.nickname !== undefined) | ||
| data.nickname = this.updateData.nickname; | ||
| if (this.updateData.phone_number !== undefined) | ||
| data.phoneNumber = this.updateData.phone_number; | ||
| if (this.updateData.terms_agreed_at !== undefined) | ||
| data.termsAgreedAt = this.updateData.terms_agreed_at | ||
| ? new Date(this.updateData.terms_agreed_at) | ||
| : null; | ||
| if (this.updateData.privacy_agreed_at !== undefined) | ||
| data.privacyAgreedAt = this.updateData.privacy_agreed_at | ||
| ? new Date(this.updateData.privacy_agreed_at) | ||
| : null; | ||
| if (this.updateData.marketing_agreed !== undefined) | ||
| data.marketingAgreed = this.updateData.marketing_agreed; | ||
| if (this.updateData.event_sms_agreed !== undefined) | ||
| data.eventSmsAgreed = this.updateData.event_sms_agreed; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
필드명 매핑이 하드코딩되어 확장성과 유지보수성 저하.
if문으로 각 필드를 개별 변환하면 새 필드 추가 시 누락 위험이 높고 코드가 장황합니다. 선언적 매핑 테이블로 전환하면 가독성과 유지보수성이 개선됩니다.
♻️ 선언적 필드 매핑
+ const FIELD_MAP: Record<string, string> = {
+ phone_number: "phoneNumber",
+ terms_agreed_at: "termsAgreedAt",
+ privacy_agreed_at: "privacyAgreedAt",
+ marketing_agreed: "marketingAgreed",
+ event_sms_agreed: "eventSmsAgreed",
+ };
+
if (this.updateData) {
const data: any = {};
- if (this.updateData.nickname !== undefined)
- data.nickname = this.updateData.nickname;
- if (this.updateData.phone_number !== undefined)
- data.phoneNumber = this.updateData.phone_number;
- // ... 반복
+ for (const [snakeKey, value] of Object.entries(this.updateData)) {
+ if (value === undefined) continue;
+ const camelKey = FIELD_MAP[snakeKey] || snakeKey;
+ data[camelKey] = snakeKey.includes("_at") && value
+ ? new Date(value)
+ : value;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (this.updateData) { | |
| const data: any = {}; | |
| if (this.updateData.nickname !== undefined) | |
| data.nickname = this.updateData.nickname; | |
| if (this.updateData.phone_number !== undefined) | |
| data.phoneNumber = this.updateData.phone_number; | |
| if (this.updateData.terms_agreed_at !== undefined) | |
| data.termsAgreedAt = this.updateData.terms_agreed_at | |
| ? new Date(this.updateData.terms_agreed_at) | |
| : null; | |
| if (this.updateData.privacy_agreed_at !== undefined) | |
| data.privacyAgreedAt = this.updateData.privacy_agreed_at | |
| ? new Date(this.updateData.privacy_agreed_at) | |
| : null; | |
| if (this.updateData.marketing_agreed !== undefined) | |
| data.marketingAgreed = this.updateData.marketing_agreed; | |
| if (this.updateData.event_sms_agreed !== undefined) | |
| data.eventSmsAgreed = this.updateData.event_sms_agreed; | |
| if (this.updateData) { | |
| const data: any = {}; | |
| const fieldMap: Record<string, string> = { | |
| nickname: "nickname", | |
| phone_number: "phoneNumber", | |
| terms_agreed_at: "termsAgreedAt", | |
| privacy_agreed_at: "privacyAgreedAt", | |
| marketing_agreed: "marketingAgreed", | |
| event_sms_agreed: "eventSmsAgreed", | |
| }; | |
| for (const [snakeKey, value] of Object.entries(this.updateData)) { | |
| if (value === undefined) continue; | |
| const camelKey = fieldMap[snakeKey]; | |
| if (!camelKey) continue; // 정의된 필드만 처리 | |
| // 날짜 필드 특수 처리 | |
| if (snakeKey.includes("_at")) { | |
| data[camelKey] = value ? new Date(value) : null; | |
| } else { | |
| data[camelKey] = value; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 200 - 217, The current manual
per-field if-blocks that copy this.updateData into the local data object (see
this.updateData and data in src/lib/supabase/server.ts) should be replaced with
a declarative mapping table: define a map from incoming snake_case keys to
target camelCase property names (and indicate which fields require Date
conversion, e.g., terms_agreed_at and privacy_agreed_at), then iterate the map
to copy and transform values only when the source key exists (converting to Date
or null where needed). Update the code around the existing block that builds
data so it uses this mapping loop instead of the repeated if statements.
| if (this.isUpsert) { | ||
| const lookupId = this.updateData.id || id; | ||
| user = await prisma.user.upsert({ | ||
| where: { id: lookupId }, | ||
| create: { | ||
| id: lookupId, | ||
| email: | ||
| this.updateData.email || | ||
| email || | ||
| `user_${Date.now()}@example.com`, | ||
| nickname: | ||
| this.updateData.nickname || nickname || `user_${Date.now()}`, | ||
| ...data, | ||
| }, | ||
| update: data, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# profiles 테이블 upsert 호출 패턴 확인
rg -nP '\.upsert\(' src/app/api/ src/lib/ --type ts -B2 -A5
# eq 필터와 updateData.id 조합 패턴 확인
rg -nP '\.eq\(["\']id["\']' src/app/api/ src/lib/ --type ts -B2 -A2Repository: Siul49/moim
Length of output: 665
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) 문제 구간 정확히 보기
sed -n '190,270p' src/lib/supabase/server.ts | nl -ba | sed -n '1,120p'
# 2) repo 내 upsert 사용처 찾기 (문자열 수준으로도 충분)
rg -n --type ts --type tsx '\.upsert\(' src/ -S
# 3) prisma.user.upsert와 where.id 패턴 확인
rg -n --type ts --type tsx 'prisma\.[A-Za-z0-9_]+\s*\.upsert\(' src/ -S
rg -n --type ts --type tsx 'where\s*:\s*\{\s*id\s*:\s*[^}]+\s*\}' src/ -S
# 4) Supabase 쿼리에서 eq('id', ...) 같은 패턴이 있는지 확인
rg -n --type ts --type tsx "\.eq\(\s*['\"]id['\"]" src/ -SRepository: Siul49/moim
Length of output: 98
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) 문제 구간 정확히 보기 (line number 없이도 최소 문맥 확보)
sed -n '200,260p' src/lib/supabase/server.ts | cat -n
# 2) repo 내 upsert 사용처 찾기
rg -n --type ts --type tsx '\.upsert\(' src/ -S
# 3) prisma.user.upsert와 where.id 패턴 확인
rg -n --type ts --type tsx 'prisma\.[A-Za-z0-9_]+\s*\.upsert\(' src/ -S
rg -n --type ts --type tsx 'where\s*:\s*\{\s*id\s*:\s*[^}]+\s*\}' src/ -S
# 4) Supabase 쿼리에서 eq('id', ...) 패턴 확인
rg -n --type ts --type tsx "\.eq\(\s*['\"]id['\"]" src/ -SRepository: Siul49/moim
Length of output: 2727
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# A) server.ts: upsert 관련 컨텍스트(함수 시그니처/파라미터 포함) 확보
sed -n '130,220p' src/lib/supabase/server.ts | cat -n
sed -n '220,320p' src/lib/supabase/server.ts | cat -n
# B) isUpsert, updateData가 어디서 셋업되는지 찾기
rg -n 'isUpsert' src/lib/supabase/server.ts
rg -n 'updateData' src/lib/supabase/server.ts
# C) repo 내 prisma.user.upsert / .upsert 사용처 전역 수집 (ts/tsx 둘 다 포함)
rg -n --glob '*.ts' --glob '*.tsx' '\.upsert\(' src/ -S
rg -n --glob '*.ts' --glob '*.tsx' 'prisma\.[A-Za-z0-9_]+\s*\.upsert\(' src/ -S
# D) Supabase 쿼리 eq('id', ...) 패턴 존재 여부 (리뷰에서 언급된 문자열 근거 확인)
rg -n --glob '*.ts' --glob '*.tsx' "\.eq\(\s*['\"]id['\"]" src/ -S
# E) prisma.user.update where id/email/nickname 분기에서 upsert 의도 대비 확인
rg -n --glob '*.ts' --glob '*.tsx' 'prisma\.user\.update\(' src/lib/supabase/server.ts -SRepository: Siul49/moim
Length of output: 8644
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) 실제 upsert 호출부(어떤 키를 넘기는지) 확인
sed -n '70,140p' src/app/dashboard/settings/page.tsx | cat -n
# 2) upsert 구현부(lookupId 기준/Mock의 한계) 문맥 더 크게 확인
sed -n '1,140p' src/lib/supabase/server.ts | cat -n
sed -n '140,270p' src/lib/supabase/server.ts | cat -n
# 3) repo 내 upsert 호출 전체 목록(대상 테이블/페이로드 패턴 확인)
rg -n --glob '*.ts' --glob '*.tsx' 'from\(\s*["'\'']profiles["'\'']\s*\)\s*\.upsert\(' src/ -S
rg -n --glob '*.ts' --glob '*.tsx' '\.upsert\(' src/ -SRepository: Siul49/moim
Length of output: 12400
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# profiles 테이블 upsert 호출부 전체 확인
rg -n --glob '*.ts' --glob '*.tsx' 'from\(["'\'']profiles["'\'']\)\.upsert\(' src -S
# profiles upsert payload에서 id/email 유무 확인(간단 문자열 근사)
rg -n --glob '*.ts' --glob '*.tsx' 'from\(["'\'']profiles["'\'']\)\.upsert\(\{[^}]*\}' src -S
# Mock에서 upsert 키 결정을 유발하는 eq 사용처 확인
rg -n --glob '*.ts' --glob '*.tsx' '\.from\(["'\'']profiles["'\'']\)\s*[\s\S]*?\.eq\((["'\''](id|email|nickname)["'\''])\s*,' src -SRepository: Siul49/moim
Length of output: 174
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# repo 전체에서 upsert 호출부 목록(테스트/핵심 경로 확인)
rg -n --glob '*.ts' --glob '*.tsx' '\.upsert\(' src -S
# 서버 mock에서 lookupId 결정이 실제로만 id를 쓰는지(이 코드 범위) 재확인
sed -n '200,255p' src/lib/supabase/server.ts | cat -nRepository: Siul49/moim
Length of output: 2690
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) repo 내 upsert 호출 전체 목록(충돌 옵션 onConflict 포함 여부 확인 목적)
rg -n --glob '*.ts' --glob '*.tsx' '\.upsert\(' src -S
# 2) onConflict 사용 여부(있다면 email/id conflict target 미지원 이슈가 현실화됨)
rg -n --glob '*.ts' --glob '*.tsx' 'onConflict' src -S
rg -n --glob '*.ts' --glob '*.tsx' '\.upsert\([^,]+,\s*\{' src -S
# 3) profiles 관련 schema/prisma 제약 확인(특히 email unique 여부)
rg -n --glob '*.prisma' 'model .*User|model .*Profile|unique|`@unique`|email' prisma -S || true
ls -la prisma || true
find . -maxdepth 3 -type f -name 'schema.prisma' -o -name '*.prisma' 2>/dev/nullRepository: Siul49/moim
Length of output: 249
🌐 Web query:
Supabase JavaScript client upsert onConflict not provided what conflict target used primary key unique constraint
💡 Result:
When using the Supabase JavaScript client's.upsert method, if the onConflict option is not explicitly provided, the operation automatically uses the table's primary key to determine conflicts [1]. To use.upsert successfully, the primary key must be included in the data payload being sent [2][1][3]. If you intend to use a column other than the primary key to handle conflicts (such as a column with a unique constraint), you must explicitly specify that column name via the onConflict option [4][1][5]. For example, to perform an upsert based on the primary key, you can simply call [6]: const { data, error } = await supabase.from('table').upsert({ id: 1, name: 'new name' }); If you wish to use a different unique column (e.g., 'username'), you must provide the onConflict parameter [4][3]: const { data, error } = await supabase.from('users').upsert({ username: 'new_user' }, { onConflict: 'username' });
Citations:
- 1: supabase/supabase-js@c8a7734
- 2: https://supabase.com/docs/reference/javascript/v1/upsert
- 3: https://supabase.com/docs/reference/javascript/next/subscribe
- 4: https://supabase.com/docs/reference/javascript/upsert
- 5: https://react-supabase.vercel.app/documentation/data/use-upsert
- 6: [supbase_flutter 2.3.1] defaultToNull Parameter Not Working as Expected supabase/supabase-flutter#816
업서트 충돌 키 로직: 현재 경로는 결정적이지만, Mock의 fallback은 Supabase 동작과 의미가 어긋날 수 있음
src/app/dashboard/settings/page.tsx의profiles업서트는 payload에id: user.id를 항상 포함하고(검출된 사용처)eq('id', ...)체인을 같이 쓰지 않아,src/lib/supabase/server.ts의lookupId = this.updateData.id || id는 실제로this.updateData.id로만 결정됩니다.- 다만 Mock은
eq('id', ...)값까지 fallback하고onConflict같은 충돌키 지정도 반영하지 않습니다. Supabase JS는onConflict를 지정하지 않으면 기본적으로 기본키(id) 충돌로 동작하며, email로 충돌 처리하려면onConflict: 'email'을 명시해야 합니다. Mock이 이 정책과 다르면 테스트가 실제 동작과 불일치합니다.
Why: Supabase 기본 업서트 충돌키는 기본키이며(payload에 필요), Mock의 eq('id', ...) fallback은 실제 클라이언트 의미와 어긋납니다.
How: Mock 업서트에서 payload에 id가 없으면 실패시키고 lookupId는 payload의 id만 사용하세요.
if (!this.updateData?.id) {
throw new Error("profiles upsert mock: payload에 id(기본키)가 필요합니다.");
}
const lookupId = this.updateData.id;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 220 - 235, The mock upsert currently
falls back to using an external id (lookupId = this.updateData.id || id) which
diverges from Supabase semantics where upsert conflict key is the primary key
(id) in the payload; update the mock in the upsert path (the this.isUpsert
branch that calls prisma.user.upsert) to require payload id only: if
this.updateData?.id is missing throw an error like "profiles upsert mock:
payload에 id(기본키)가 필요합니다." and set lookupId = this.updateData.id (do not fall
back to the external id or eq value) so the mock matches Supabase behavior.
| maybeSingle() { | ||
| return this; | ||
| } | ||
|
|
||
| single() { | ||
| return this; | ||
| } |
There was a problem hiding this comment.
maybeSingle과 single의 동작 차이 미구현.
Supabase에서 maybeSingle()은 결과 없을 때 data: null, single()은 error 반환이 원칙입니다. 현재 구현은 둘 다 this만 반환하고 execute()에서 차이를 두지 않아 실제 동작과 불일치합니다.
🔧 maybeSingle/single 차이 구현
+ private requireSingle = false;
+
maybeSingle() {
+ this.requireSingle = false;
return this;
}
single() {
+ this.requireSingle = true;
return this;
}
async execute() {
if (this.table === "profiles") {
// ... 조회 로직
if (!user) {
- return { data: null, error: null };
+ return {
+ data: null,
+ error: this.requireSingle
+ ? new Error("No rows found")
+ : null
+ };
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| maybeSingle() { | |
| return this; | |
| } | |
| single() { | |
| return this; | |
| } | |
| private requireSingle = false; | |
| maybeSingle() { | |
| this.requireSingle = false; | |
| return this; | |
| } | |
| single() { | |
| this.requireSingle = true; | |
| return this; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 309 - 315, maybeSingle()와 single()가
현재 단순히 this를 반환해 실제 동작 차이를 반영하지 않습니다; 변경: make maybeSingle()와 single() set an
internal mode flag (e.g., this._expect = 'maybeSingle' or 'single') on the query
builder, then update execute() to inspect this._expect and enforce Supabase
semantics: for 'maybeSingle' return { data: null, error: null } when no rows;
for 'single' return an error when no rows (and return an error when multiple
rows are returned in both modes if applicable). Update any result/error
construction paths in execute() to produce the appropriate Postgrest-like error
objects for the 'single' case.
| export async function GET(request: Request) { | ||
| const { origin } = new URL(request.url); | ||
| const authUrl = buildAuthUrl(undefined, origin); | ||
| return NextResponse.redirect(authUrl); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: callback route에서 state 파라미터 검증 확인
rg -n -A 10 'searchParams.get\("state"\)' src/app/api/google/callback/route.tsRepository: Siul49/moim
Length of output: 37
Google OAuth state(CSRF) 방어 누락—콜백 검증까지 추가 필요
/src/app/api/google/auth/route.ts에서 buildAuthUrl(undefined, origin)로 state를 보내지 않고, /src/app/api/google/callback/route.ts에는 state를 읽어 검증하는 로직이 없습니다.
Why: OAuth 요청에 state가 없으면, 공격자가 피해자의 인증 플로우를 하이재킹/교차 연결해 의도치 않은 계정 연결을 유도할 수 있습니다.
How: auth에서 임의 state를 생성해 httpOnly 쿠키로 저장하고, callback에서 쿼리 state와 쿠키 값을 비교해 불일치 시 즉시 중단하세요.
// src/app/api/google/auth/route.ts (핵심 변경 예시)
import { randomBytes } from "crypto";
export async function GET(request: Request) {
const { origin } = new URL(request.url);
const state = randomBytes(32).toString("hex");
const authUrl = buildAuthUrl(state, origin);
const res = NextResponse.redirect(authUrl);
res.cookies.set("oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10,
path: "/",
});
return res;
}// src/app/api/google/callback/route.ts (핵심 삽입 예시)
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = cookies().get("oauth_state")?.value;
if (!state || !expected || state !== expected) {
return NextResponse.json({ error: "invalid oauth state" }, { status: 401 });
}
// 기존: exchangeCodeForTokens(code, origin) ...
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/google/auth/route.ts` around lines 10 - 14, The auth route's GET
currently calls buildAuthUrl(undefined, origin) without an anti-CSRF state and
doesn't set a cookie, and the callback GET lacks state verification; fix by
generating a cryptographically random state (e.g., via randomBytes) in the auth
GET, pass it into buildAuthUrl(state, origin), and set it as an httpOnly,
secure, sameSite cookie (name like "oauth_state") on the NextResponse.redirect;
then in the callback GET, read the incoming query state and compare it to
cookies().get("oauth_state")?.value and immediately abort (401) if missing or
mismatched before calling exchangeCodeForTokens or continuing the flow.
| export const POST = createApiHandler( | ||
| { | ||
| bodySchema: ConnectSchema, | ||
| }, | ||
| async ({ body }) => { | ||
| const { appleId, appPassword } = body; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 설명: iCloud/Google/Everytime API 라우트들의 requireAuth 사용 패턴 확인
echo "=== createApiHandler 호출 시 requireAuth 옵션 사용 여부 ==="
rg -nP 'createApiHandler\s*\(' src/app/api/{icloud,google,everytime}/ -A 5
echo ""
echo "=== 세션 기반 쿠키 저장 함수 호출 위치 ==="
rg -nP 'saveConnection|setGoogleTokenCookies' src/app/api/{icloud,google}/ -C 3Repository: Siul49/moim
Length of output: 5377
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) createApiHandler 구현(특히 requireAuth 기본값/동작) 확인
echo "=== createApiHandler 구현 위치/내용(주요 부분) ==="
FILE=$(rg -n --hidden --follow "function createApiHandler|createApiHandler\s*=\s*\(" src/lib -S | head -n 1 | cut -d: -f1 || true)
if [ -z "${FILE:-}" ]; then
FILE=$(rg -n --hidden --follow "createApiHandler" src/lib -S | rg -m1 -o "src/.*" || true)
fi
echo "createApiHandler 후보 파일: ${FILE:-<미확인>}"
if [ -n "${FILE:-}" ] && [ -f "$FILE" ]; then
nl -ba "$FILE" | sed -n '1,220p'
fi
# 2) icloud connect route 전체(최소한 saveConnection 호출부 포함) 확인
echo ""
echo "=== src/app/api/icloud/connect/route.ts 핵심 구간 ==="
nl -ba src/app/api/icloud/connect/route.ts | sed -n '1,160p'
# 3) saveConnection 구현(세션 의존/쿠키 속성 포함) 확인
echo ""
echo "=== saveConnection 구현 ==="
SAVEFILE=$(rg -n "export\s+async\s+function\s+saveConnection|export\s+function\s+saveConnection|saveConnection\s*=" src/lib -S | head -n 1 | cut -d: -f1 || true)
echo "saveConnection 후보 파일: ${SAVEFILE:-<미확인>}"
if [ -n "${SAVEFILE:-}" ] && [ -f "$SAVEFILE" ]; then
nl -ba "$SAVEFILE" | sed -n '1,220p'
fi
# 4) connection-cookie가 어떤 쿠키 네이밍/속성을 쓰는지(세션 키 사용 여부) 더 좁게 확인
echo ""
echo "=== connection-cookie 관련 쿠키/세션 사용 검색 ==="
rg -n "cookie|set-cookie|session|sessionId|req\.cookies|cookies\(|NextResponse\.cookies|headers\.cookie" src/lib/caldav/connection-cookie -S || trueRepository: Siul49/moim
Length of output: 205
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== createApiHandler 구현(파일) ==="
API_HANDLER_FILE="src/lib/api-handler.ts"
if [ -f "$API_HANDLER_FILE" ]; then
echo "파일 존재: $API_HANDLER_FILE"
cat -n "$API_HANDLER_FILE" | sed -n '1,260p'
else
echo "파일 없음: $API_HANDLER_FILE"
fi
echo ""
echo "=== src/app/api/icloud/connect/route.ts(파일 전체 중 앞부분) ==="
ROUTE_FILE="src/app/api/icloud/connect/route.ts"
if [ -f "$ROUTE_FILE" ]; then
cat -n "$ROUTE_FILE" | sed -n '1,220p'
fi
echo ""
echo "=== saveConnection 구현(파일) ==="
# connection-cookie module
COOKIE_FILE=$(rg -n "saveConnection" src/lib -S | head -n 5)
echo "saveConnection 관련 검색 결과(상위 5개):"
echo "$COOKIE_FILE"
CONNECTION_COOKIE_FILE="src/lib/caldav/connection-cookie.ts"
if [ -f "$CONNECTION_COOKIE_FILE" ]; then
echo ""
echo "파일 존재: $CONNECTION_COOKIE_FILE"
cat -n "$CONNECTION_COOKIE_FILE" | sed -n '1,260p'
else
echo "파일 없음: $CONNECTION_COOKIE_FILE"
fi
echo ""
echo "=== connection-cookie가 설정하는 쿠키/속성 요약 ==="
if [ -f "src/lib/caldav/connection-cookie.ts" ]; then
rg -n "setCookie|Set-Cookie|cookie|session" src/lib/caldav/connection-cookie.ts -S || true
fiRepository: Siul49/moim
Length of output: 11593
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== icloud 라우트 createApiHandler options(requireAuth) 확인 ==="
rg -n "src/app/api/icloud/.*/route\.ts" -S --files-with-matches "" src/app/api/icloud || true
rg -n "createApiHandler\s*\(" src/app/api/icloud -S
echo ""
echo "=== google 라우트 createApiHandler options(requireAuth) 확인 ==="
rg -n "createApiHandler\s*\(" src/app/api/google -S
echo ""
echo "=== icloud connect에서 CONNECTION_COOKIE_NAME 덮어쓰기 동작 확인(동일 쿠키 set 경로) ==="
rg -n "icloud_connection" src/lib/caldav/connection-cookie.ts -n
echo ""
echo "=== icloud connect/calendars/events/disconnect에서 getConnection/getConnectionAuth 사용 위치 확인 ==="
rg -n "getConnectionAuth|getConnection\(" src/app/api/icloud -SRepository: Siul49/moim
Length of output: 1862
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in \
src/app/api/icloud/connect/route.ts \
src/app/api/icloud/calendars/route.ts \
src/app/api/icloud/events/create/route.ts \
src/app/api/icloud/events/query/route.ts \
src/app/api/icloud/disconnect/route.ts \
src/app/api/google/disconnect/route.ts \
src/app/api/google/calendars/route.ts \
src/app/api/google/events/create/route.ts \
src/app/api/google/events/query/route.ts
do
echo ""
echo "===== $f ====="
if [ -f "$f" ]; then
cat -n "$f" | sed -n '1,220p'
else
echo "파일 없음: $f"
fi
doneRepository: Siul49/moim
Length of output: 19948
icloud/connect를 포함한 iCloud API 라우트에 인증/CSRF 방어 필요
createApiHandler는 requireAuth: true가 있을 때만 getSession()으로 인증을 검사하는데, src/app/api/icloud/connect/route.ts(22-28)에서는 requireAuth가 없어 saveConnection()이 icloud_connection(HttpOnly) 쿠키를 세션 검증 없이 설정합니다. 이후 icloud/calendars, icloud/events/*, icloud/disconnect도 동일하게 getConnectionAuth()로 쿠키만 보고 동작해, CSRF/쿠키 오염 시 iCloud 연동 권한 경계가 깨질 수 있습니다.
Why: 사용자별 격리가 있어야 하는 연동 상태가 세션 경계 없이 쿠키 한 개로 결정됩니다.
How: iCloud 연동 상태를 만드는 라우트부터 최소 requireAuth: true를 강제하세요(필요 시 CSRF 토큰/Origin-Referer 검증도 추가).
export const POST = createApiHandler(
{
requireAuth: true,
bodySchema: ConnectSchema,
},
async ({ body }) => {
// ...
},
);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/icloud/connect/route.ts` around lines 22 - 28, The POST handler
in the iCloud connect route creates the icloud_connection cookie without session
verification; update the createApiHandler call in
src/app/api/icloud/connect/route.ts (the POST route) to require authentication
by adding requireAuth: true to its options so saveConnection cannot set the
cookie without a valid session; also review related handlers that call
getConnectionAuth (icloud/calendars, icloud/events/*, icloud/disconnect) and, if
needed, add CSRF/Origin-Referer checks or requireAuth there as well to ensure
the icloud_connection cookie is tied to a verified user session.
| export const POST = createApiHandler({}, async () => { | ||
| await clearConnection(); | ||
| return NextResponse.json({ success: true }); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 설명: disconnect 라우트와 connect 라우트의 인증 요구사항 비교
echo "=== iCloud disconnect 라우트의 requireAuth 사용 여부 ==="
rg -nP 'createApiHandler' src/app/api/icloud/disconnect/route.ts -A 3
echo ""
echo "=== 다른 disconnect 라우트들의 requireAuth 패턴 ==="
rg -nP 'createApiHandler' src/app/api/{google,icloud}/disconnect/ -A 3Repository: Siul49/moim
Length of output: 1450
🏁 Script executed:
#!/bin/bash
# 설명: icloud/google connect/ disconnect 라우트에서 requireAuth 사용 여부 확인 + createApiHandler 기본 동작 확인
set -e
echo "=== icloud connect: requireAuth 여부 ==="
rg -nP 'createApiHandler' src/app/api/icloud/connect/route.ts -A 5 || true
echo ""
echo "=== google connect: requireAuth 여부 ==="
rg -nP 'createApiHandler' src/app/api/google/connect/route.ts -A 5 || true
echo ""
echo "=== icloud disconnect: requireAuth 여부(재확인) ==="
rg -nP 'createApiHandler' src/app/api/icloud/disconnect/route.ts -A 5
echo ""
echo "=== createApiHandler 구현(옵션 기본값 확인) ==="
rg -nP 'function createApiHandler|const createApiHandler|export function createApiHandler|export const createApiHandler' -S src/lib/api-handler.ts src/lib/api-handler/* src/lib/api-handler/** 2>/dev/null || true
# createApiHandler 실제 파일을 찾아서 열어보기(검색 기반)
echo ""
echo "=== createApiHandler 정의 파일 찾기 ==="
fd -a createApiHandler -t f src/lib || trueRepository: Siul49/moim
Length of output: 1027
🏁 Script executed:
#!/bin/bash
# 설명: createApiHandler 구현과 icloud connect/icloud disconnect의 requireAuth 실제 여부 파악
set -euo pipefail
echo "=== src/lib/api-handler.ts: createApiHandler 시그니처/옵션/requireAuth 처리 ==="
nl -ba src/lib/api-handler.ts | sed -n '1,220p'
echo ""
echo "=== src/app/api/icloud/connect/route.ts: POST createApiHandler 옵션 전체 ==="
nl -ba src/app/api/icloud/connect/route.ts | sed -n '1,140p'
echo ""
echo "=== src/app/api/google/disconnect/route.ts: POST createApiHandler 옵션 ==="
nl -ba src/app/api/google/disconnect/route.ts | sed -n '1,120p'
echo ""
echo "=== src/app/api/icloud/disconnect/route.ts: POST createApiHandler 옵션 ==="
nl -ba src/app/api/icloud/disconnect/route.ts | sed -n '1,80p'Repository: Siul49/moim
Length of output: 188
🏁 Script executed:
#!/bin/bash
# 설명: createApiHandler 구현과 icloud connect/icloud disconnect의 requireAuth 실제 여부 파악
set -euo pipefail
echo "=== src/lib/api-handler.ts: createApiHandler 시그니처/옵션/requireAuth 처리 ==="
nl -ba src/lib/api-handler.ts | sed -n '1,220p'
echo ""
echo "=== src/app/api/icloud/connect/route.ts: POST createApiHandler 옵션 전체 ==="
nl -ba src/app/api/icloud/connect/route.ts | sed -n '1,140p'
echo ""
echo "=== src/app/api/google/disconnect/route.ts: POST createApiHandler 옵션 ==="
nl -ba src/app/api/google/disconnect/route.ts | sed -n '1,120p'
echo ""
echo "=== src/app/api/icloud/disconnect/route.ts: POST createApiHandler 옵션 ==="
nl -ba src/app/api/icloud/disconnect/route.ts | sed -n '1,80p'Repository: Siul49/moim
Length of output: 188
🏁 Script executed:
#!/bin/bash
# 설명: nl 미지원 환경 -> cat -n / sed로 파일 구간 확인
set -euo pipefail
echo "=== src/lib/api-handler.ts: createApiHandler 시그니처/옵션/requireAuth 처리(상단 220줄) ==="
cat -n src/lib/api-handler.ts | sed -n '1,220p'
echo ""
echo "=== src/app/api/icloud/connect/route.ts: POST createApiHandler 옵션 전체(1~140줄) ==="
cat -n src/app/api/icloud/connect/route.ts | sed -n '1,140p'
echo ""
echo "=== src/app/api/google/disconnect/route.ts: POST createApiHandler 옵션(1~140줄) ==="
cat -n src/app/api/google/disconnect/route.ts | sed -n '1,140p'
echo ""
echo "=== src/app/api/icloud/disconnect/route.ts: POST createApiHandler 옵션(1~80줄) ==="
cat -n src/app/api/icloud/disconnect/route.ts | sed -n '1,80p'Repository: Siul49/moim
Length of output: 8638
🏁 Script executed:
#!/bin/bash
# 설명: clearConnection()이 세션/권한 없이 쿠키를 삭제하는지 확인
set -euo pipefail
echo "=== clearConnection 구현 ==="
rg -n "function clearConnection|export async function clearConnection|export function clearConnection|clearConnection\\(" -S src/lib/caldav/connection-cookie.ts src/lib/caldav -S || true
echo ""
# 파일이 정확히 어떤지 찾아서 출력
FILE="$(fd -a "connection-cookie" -t f src/lib/caldav || true | head -n 20)"
echo "탐색된 파일: $FILE"
# 연결-cookie 관련 파일을 더 찾아서 모두 출력(짧게)
fd -t f "connection-cookie*" src/lib/caldav | head -n 20 | while read -r f; do
echo ""
echo "=== ${f} (상단 200줄) ==="
sed -n '1,200p' "$f"
doneRepository: Siul49/moim
Length of output: 3549
[보안] /api/icloud/disconnect requireAuth 누락(세션 없는 쿠키 삭제 가능)
Why: createApiHandler는 options.requireAuth일 때만 getSession()으로 인증을 검사하는데, iCloud disconnect는 createApiHandler({}, ...)로 세션 검증 없이 clearConnection()을 실행합니다. clearConnection()은 icloud_connection 쿠키를 세션/소유자 확인 없이 무조건 delete 합니다.
How: iCloud connect/disconnect 모두 google/disconnect처럼 requireAuth: true를 추가하세요(추가로 CSRF 방어도 필요).
권장 수정 예시
// src/app/api/icloud/disconnect/route.ts
export const POST = createApiHandler(
{ requireAuth: true },
async () => {
await clearConnection();
return NextResponse.json({ success: true });
},
);
// src/app/api/icloud/connect/route.ts (동일 패턴 적용)
export const POST = createApiHandler(
{ requireAuth: true, bodySchema: ConnectSchema },
async ({ body }) => {
...
},
);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/icloud/disconnect/route.ts` around lines 7 - 10, The POST handler
for iCloud disconnect uses createApiHandler without auth, allowing
clearConnection() to delete the icloud_connection cookie without verifying
session; update the POST export in src/app/api/icloud/disconnect/route.ts to
call createApiHandler with { requireAuth: true } (matching the google/disconnect
pattern) so getSession() is enforced before clearConnection(), and apply the
same change to the iCloud connect handler (createApiHandler({ requireAuth: true,
bodySchema: ConnectSchema }, ...)); also ensure CSRF protections are enabled in
the handler options or middleware as appropriate.
| describe("getNaverToken", () => { | ||
| test("토큰 교환 요청 시 올바른 redirect_uri를 포함하여 요청한다", async () => { | ||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: () => | ||
| Promise.resolve({ | ||
| access_token: "access-token-123", | ||
| token_type: "bearer", | ||
| expires_in: "3600", | ||
| refresh_token: "refresh-token-456", | ||
| }), | ||
| }); | ||
|
|
||
| const tokenData = await getNaverToken( | ||
| "auth-code-111", | ||
| "state-222", | ||
| "https://dynamic-origin.com", | ||
| ); | ||
|
|
||
| expect(tokenData.access_token).toBe("access-token-123"); | ||
|
|
||
| const [url, options] = mockFetch.mock.calls[0]; | ||
| expect(url).toBe("https://nid.naver.com/oauth2.0/token"); | ||
| expect(options.method).toBe("POST"); | ||
| expect(options.body).toContain( | ||
| "redirect_uri=https%3A%2F%2Fdynamic-origin.com%2Fapi%2Fauth%2Fnaver%2Fcallback", | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
토큰 교환 실패 케이스 추가 필요.
성공 경로만 검증하고 있으나, 실제 구현에는 !res.ok, data.error, !data.access_token 실패 분기가 존재합니다. 네트워크 오류나 인증 거부는 운영 환경에서 빈번하게 발생하므로, 이들 예외 경로를 테스트하지 않으면 예외 처리 로직이 깨져도 감지할 수 없습니다.
Why: TDD 원칙상 모든 분기(성공/실패)를 테스트해야 회귀를 방지하고 예외 처리 신뢰성을 보장할 수 있습니다.
How: 다음 실패 케이스 추가를 권장합니다.
🧪 제안: 실패 케이스 테스트 추가
+ test("토큰 교환 실패 시 에러를 던진다", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ });
+
+ await expect(
+ getNaverToken("auth-code-111", "state-222"),
+ ).rejects.toThrow("네이버 토큰 발급 실패");
+ });
+
+ test("네이버 에러 응답 시 에러를 던진다", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ error: "invalid_grant",
+ error_description: "authorization code is invalid",
+ }),
+ });
+
+ await expect(
+ getNaverToken("bad-code", "state-222"),
+ ).rejects.toThrow("네이버 토큰 발급 오류");
+ });
+
+ test("access_token 누락 시 에러를 던진다", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ await expect(
+ getNaverToken("auth-code-111", "state-222"),
+ ).rejects.toThrow("access_token 누락");
+ });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/__tests__/naver.test.ts` around lines 56 - 84, Add tests for the
failure branches of getNaverToken: add cases that assert behavior when mockFetch
resolves with ok: false, when the response JSON contains an error (data.error),
and when the response lacks access_token (no data.access_token). For each case,
call getNaverToken with the same parameters as the success test and mock fetch
to return the corresponding failure payloads or throw a network error, then
assert that the function throws/rejects with the expected error or message (use
the same rejection shape your implementation throws). Reference getNaverToken
and mockFetch.mockResolvedValueOnce/mockRejectedValueOnce to locate where to add
these tests. Ensure assertions cover !res.ok, data.error, and missing
access_token branches.
| function getRedirectUri(origin?: string): string { | ||
| const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI; | ||
| if (explicit) return explicit; | ||
|
|
||
| const base = | ||
| origin || process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:4000"; | ||
| return `${base}/api/google/callback`; | ||
| } |
There was a problem hiding this comment.
origin 파라미터를 검증 없이 redirect URI 구성에 사용
getRedirectUri가 외부 입력 origin을 검증 없이 URL 생성에 사용합니다. Next.js의 request.url에서 추출한 origin은 Host 헤더에 영향받을 수 있으며, 명시적 allowlist 검증이 없으면 잠재적으로 Host 헤더 인젝션 공격 표면이 됩니다.
Why: Google OAuth는 등록된 redirect_uri만 허용하므로 실질적 위험은 낮지만, 방어적 코딩 원칙상 신뢰할 수 있는 origin만 허용해야 합니다.
How: 허용된 origin allowlist를 환경변수로 정의하고 검증 로직을 추가하세요.
🛡️ origin allowlist 검증 추가 제안
function getRedirectUri(origin?: string): string {
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
if (explicit) return explicit;
+ // origin이 제공되었으면 allowlist 검증
+ if (origin) {
+ const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(",");
+ if (!allowedOrigins.includes(origin)) {
+ throw new Error(`허용되지 않은 origin: ${origin}`);
+ }
+ }
+
const base =
origin || process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:4000";
return `${base}/api/google/callback`;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/google/auth.ts` around lines 37 - 44, getRedirectUri currently uses
the external origin parameter directly; add a whitelist check to avoid Host
header injection by introducing an environment allowlist (e.g.
GOOGLE_ALLOWED_ORIGINS as a comma-separated list) and validate the origin
argument against that list inside getRedirectUri; if origin is missing or not in
the allowlist, fall back to process.env.NEXT_PUBLIC_BASE_URL or the default
"http://localhost:4000" and optionally log/throw a warning so only trusted
origins are used when building the `${base}/api/google/callback` redirect URI.
8229334 to
1df79a1
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/app/api/schedules/[id]/route.ts (1)
34-36:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
hostToken쿼리값도 trim 후 우선순위 판단에 써야 합니다.Line 34에서 쿼리 파라미터를 원문 그대로 우선 적용하고 있습니다.
" abc "처럼 공백이 섞인 정상 토큰도getScheduleForHost()에서 실패하고, 쿼리 우선순위 때문에 정상 쿠키 fallback까지 막혀 403으로 떨어집니다.Why: PATCH는 이미
trimmedBodyHostToken으로 같은 입력 정규화를 하고 있는데, GET만 빠져 있어서 동일 토큰이 진입 경로에 따라 다르게 동작합니다.
How: 쿼리 토큰을 한 번 trim 해서 빈 문자열이면null로 정규화한 뒤 기존 우선순위를 유지하세요.최소 수정 예시
- const queryHostToken = req.nextUrl.searchParams.get("hostToken"); + const rawQueryHostToken = req.nextUrl.searchParams.get("hostToken"); + const queryHostToken = rawQueryHostToken?.trim() || null; const cookieHostToken = req.cookies.get(getHostTokenCookieName(id))?.value; const hostToken = queryHostToken ?? cookieHostToken;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/api/schedules/`[id]/route.ts around lines 34 - 36, The query host token (queryHostToken) must be trimmed and normalized before deciding priority so whitespace-only tokens don't block cookie fallback; update the logic that sets hostToken to first trim queryHostToken (e.g., const trimmedQueryHostToken = queryHostToken?.trim() || null but treat empty string as null) and then compute hostToken = trimmedQueryHostToken ?? cookieHostToken, keeping getHostTokenCookieName(id) and getScheduleForHost() usage unchanged.src/lib/api-handler.ts (1)
47-48:⚠️ Potential issue | 🟠 Major | ⚡ Quick win공개 라우트도 매번 세션을 조회합니다.
Why:
requireAuth가 꺼진 핸들러에서도getSession()이 항상 실행됩니다. 이번 PR에서src/app/api/everytime/timetable/route.ts같은 공개 엔드포인트까지 이 래퍼를 타므로, 인증 스토어/Supabase 쪽 문제가 생기면 원래 익명으로 처리돼야 할 요청도 같이 실패하고, 성공하더라도 모든 공개 요청에 불필요한 I/O가 추가됩니다.How: 세션이 정말 필요한 경우에만 로드하도록 옵션을 분리하세요.
🔧 최소 수정 예시
export interface ApiHandlerOptions< TBodySchema extends z.ZodTypeAny = z.ZodTypeAny, > { requireAuth?: boolean; + loadSession?: boolean; bodySchema?: TBodySchema; } @@ - const session = await getSession(); + const session = + options.requireAuth || options.loadSession ? await getSession() : null; if (options.requireAuth && !session) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/api-handler.ts` around lines 47 - 48, The wrapper currently calls getSession() unconditionally which causes public routes to always perform session I/O; change the wrapper's options to accept a requireAuth boolean (default false) and only invoke await getSession() when requireAuth is true, e.g., guard the getSession call inside the handler (referencing getSession and the wrapper function that currently calls it), then pass the session into downstream logic only when present and ensure authentication checks (throwing/returning 401) occur only when requireAuth is true.src/lib/supabase/__tests__/supabase.test.ts (1)
16-23:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win새 fallback 성공 경로가 테스트되지 않습니다.
Why: 이번 변경의 핵심은 테스트 런타임에서 기본 Supabase 설정을 보강하는 동작인데, 현재 스위트는
SUPABASE_TEST_NO_FALLBACK를 항상 켜서 실패 경로만 검증합니다. 그래서 fallback 회귀가 나도 이 파일은 계속 통과합니다.How: opt-out을 끈 별도 케이스를 추가해서 브라우저/서버 클라이언트가 공개 인터페이스 기준으로 정상 생성되는지 확인하세요.
코드 제안
+ it("테스트 런타임에서는 브라우저 클라이언트를 fallback 설정으로 생성한다", () => { + delete process.env.SUPABASE_TEST_NO_FALLBACK; + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + expect(() => createBrowserClient()).not.toThrow(); + }); + + it("테스트 런타임에서는 서버 클라이언트를 fallback 설정으로 생성한다", async () => { + delete process.env.SUPABASE_TEST_NO_FALLBACK; + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + await expect(createServerClient()).resolves.toBeDefined(); + });As per coding guidelines,
**/__tests__/**: 경계값, 에러 케이스, 빈 입력 등 엣지 케이스 커버리지를 평가하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/supabase/__tests__/supabase.test.ts` around lines 16 - 23, The current tests always set SUPABASE_TEST_NO_FALLBACK so only the failure path is covered; add a separate test that omits/unsets SUPABASE_TEST_NO_FALLBACK (use vi.resetModules() and adjust process.env in a new it/test block) and then import the supabase module to assert the normal fallback path creates valid clients via the module's public factory (e.g., call the exported createSupabaseClient/createSupabaseClients/initSupabase function or default export) and assert non-null/expected shape for browser/server clients; keep the existing beforeEach (vi.resetModules and process.env) for other cases and only change env for this new positive test.Source: Coding guidelines
♻️ Duplicate comments (3)
src/lib/api-handler.ts (2)
49-53:⚠️ Potential issue | 🟠 Major인증 실패만 중앙 에러 포맷을 우회하고 있습니다.
Why:
apiErrorHandler는 이제MoimError의statusCode/code/message/details를 표준 응답으로 내려주는데, 여기만 직접401JSON을 반환해서 인증 실패 응답 모양이 다른 예외 경로와 달라집니다. 클라이언트는 같은 인증 오류를 두 가지 포맷으로 처리해야 합니다.How:
UnauthorizedError를 던지고 공통 에러 핸들러 한 곳에서 포맷팅하세요.🔧 최소 수정 예시
+import { UnauthorizedError } from "`@/lib/errors`"; @@ const session = await getSession(); if (options.requireAuth && !session) { - return NextResponse.json( - { success: false, message: "인증이 필요합니다." }, - { status: 401 }, - ); + throw new UnauthorizedError(); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/api-handler.ts` around lines 49 - 53, Replace the direct 401 JSON return in the session check with throwing the centralized UnauthorizedError so responses use the same MoimError format; specifically, in the function where you check options.requireAuth and session (the block that currently returns NextResponse.json when no session), import/throw UnauthorizedError instead and let apiErrorHandler (the common error handler that formats MoimError with statusCode/code/message/details) handle the response formatting.
37-45:⚠️ Potential issue | 🟠 Major
params부재를 타입 단언으로 숨기면 동적 라우트가 조용히 깨집니다.Why:
routeContext가 없을 때{} as TParams가 들어가므로, 동적 라우트에서params.id를 읽는 코드는 컴파일을 통과하고 런타임에서만undefined를 맞습니다. 이건 래퍼가 막아야 할 계약 위반입니다.How: 기본값을 빈 객체로 단언하지 말고,
Partial<TParams>로 노출한 뒤 각 라우트가 필수 파라미터를 명시적으로 검증하게 바꾸세요.🔧 최소 수정 예시
export interface ApiContext<TBody = unknown, TParams = unknown> { req: NextRequest; session: Session | null; body: TBody; - params: TParams; + params: Partial<TParams>; } @@ - let params: TParams = {} as TParams; + let params: Partial<TParams> = {}; if ( routeContext && typeof routeContext === "object" && "params" in routeContext ) { const context = routeContext as { params: Promise<TParams> }; params = await context.params; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/api-handler.ts` around lines 37 - 45, The current code in api-handler.ts creates params with a forced "{} as TParams" which hides missing required route params at compile time; change the local variable declaration to let params: Partial<TParams> = {}; when extracting from routeContext keep const context = routeContext as { params: Promise<TParams> } and assign awaited value into the Partial<TParams> variable (params = await context.params), and then update any callers of the function (or the function's return type) to accept Partial<TParams> and explicitly validate required fields (e.g., check for params.id) so dynamic routes fail fast instead of producing undefined at runtime.src/lib/__tests__/api-handler.test.ts (1)
70-87: 🧹 Nitpick | 🔵 Trivial
MoimError분기 회귀를 막는 테스트가 없습니다.Why: 지금 테스트는 일반
Error의 500 응답만 확인합니다. 그런데 프로덕션 코드는MoimError를 별도 포맷으로 내려주도록 바뀌었으니,UnauthorizedError/ForbiddenError가 401/403과code를 유지하는지 고정해 두지 않으면 다음 리팩토링에서 쉽게 깨집니다.How:
handler가MoimError하위 클래스를 던지는 케이스를 추가해status,message,code를 함께 검증하세요.🧪 최소 추가 예시
import { ForbiddenError, UnauthorizedError } from "`@/lib/errors`"; it("UnauthorizedError를 401 응답으로 매핑한다", async () => { const handler = createApiHandler({}, async () => { throw new UnauthorizedError(); }); const res = await handler(new NextRequest("http://localhost/api/test")); const body = await res.json(); expect(res.status).toBe(401); expect(body.message).toBe("인증이 필요합니다."); expect(body.code).toBe("UNAUTHORIZED"); }); it("ForbiddenError를 403 응답으로 매핑한다", async () => { const handler = createApiHandler({}, async () => { throw new ForbiddenError(); }); const res = await handler(new NextRequest("http://localhost/api/test")); const body = await res.json(); expect(res.status).toBe(403); expect(body.code).toBe("FORBIDDEN"); });As per coding guidelines,
__tests__파일은 경계값, 에러 케이스, 빈 입력 등 엣지 케이스 커버리지를 평가하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/__tests__/api-handler.test.ts` around lines 70 - 87, The test suite only checks generic Error -> 500 behavior; add tests that throw MoimError subclasses to lock in special handling: import UnauthorizedError and ForbiddenError from "`@/lib/errors`" and add two tests that create handlers via createApiHandler({}, async () => { throw new UnauthorizedError(); }) and createApiHandler({}, async () => { throw new ForbiddenError(); }), call them with a NextRequest, then assert the response status is 401 for UnauthorizedError and 403 for ForbiddenError and verify the JSON body contains the expected message and code fields (e.g., message "인증이 필요합니다." and code "UNAUTHORIZED" for UnauthorizedError; code "FORBIDDEN" for ForbiddenError) to prevent regressions of MoimError mapping.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/api/auth/me/route.ts`:
- Around line 19-20: Replace the UnauthorizedError thrown when profile is
missing in the /api/auth/me handler with a domain-specific ProfileNotFoundError
so frontends don't treat missing profile as an auth failure; update the check in
route.ts (the if (!profile) branch inside the createApiHandler({ requireAuth:
true }) flow) to throw ProfileNotFoundError, add the ProfileNotFoundError class
extending MoimError in src/lib/errors.ts with an appropriate code (e.g.,
"PROFILE_NOT_FOUND") and status (e.g., 409), and add a unit test in
src/lib/__tests__/errors.test.ts asserting the new error's statusCode and code.
In `@src/lib/supabase/env.ts`:
- Around line 12-16: The current condition in src/lib/supabase/env.ts lets
NODE_ENV==="test" or process.env.VITEST trigger the client fallback even when
options.isServer===true (so createServerClient() can be incorrectly replaced
with test fallback); change the boolean expression to ensure the test/runtime
checks are evaluated only under the client branch (i.e., group them with
!options.isServer and typeof window !== "undefined") so that server calls
(options.isServer===true) never fall back to example URL/key; keep the outer
SUPABASE_TEST_NO_FALLBACK check behavior intact and update the condition around
options.isServer in the same function where options.isServer is read.
In `@src/lib/supabase/server.ts`:
- Around line 105-112: The mock updateUser currently always overwrites
caller-provided nickname because it builds user_metadata as { ...data, nickname:
mockNickname }; change this to preserve an incoming nickname and only fallback
to mockNickname when data.nickname is missing or empty (e.g., use nickname:
data?.nickname ?? mockNickname or spread with nickname last only when absent).
Update the updateUser mock to check data.nickname (and handle
undefined/null/empty) and return user_metadata that respects the caller's value,
keeping typesafe checks around data before accessing nickname.
- Around line 15-31: The code currently allows mock sessions whenever
e2e_mock_uid and e2e_mock_email cookies are present; restrict mock mode to the
server-side flag only by changing the control flow so isE2ETest is required
before cookies can enable a mock session. Concretely, ensure isMockSession is
only true when isE2ETest is true (e.g. compute isMockSession = isE2ETest &&
(mockUid && mockEmail)), and only use mockUid, mockEmail, mockNickname to
populate a mock user after verifying isE2ETest; update the branch that currently
checks isMockSession so cookies alone cannot trigger the mock client.
---
Outside diff comments:
In `@src/app/api/schedules/`[id]/route.ts:
- Around line 34-36: The query host token (queryHostToken) must be trimmed and
normalized before deciding priority so whitespace-only tokens don't block cookie
fallback; update the logic that sets hostToken to first trim queryHostToken
(e.g., const trimmedQueryHostToken = queryHostToken?.trim() || null but treat
empty string as null) and then compute hostToken = trimmedQueryHostToken ??
cookieHostToken, keeping getHostTokenCookieName(id) and getScheduleForHost()
usage unchanged.
In `@src/lib/api-handler.ts`:
- Around line 47-48: The wrapper currently calls getSession() unconditionally
which causes public routes to always perform session I/O; change the wrapper's
options to accept a requireAuth boolean (default false) and only invoke await
getSession() when requireAuth is true, e.g., guard the getSession call inside
the handler (referencing getSession and the wrapper function that currently
calls it), then pass the session into downstream logic only when present and
ensure authentication checks (throwing/returning 401) occur only when
requireAuth is true.
In `@src/lib/supabase/__tests__/supabase.test.ts`:
- Around line 16-23: The current tests always set SUPABASE_TEST_NO_FALLBACK so
only the failure path is covered; add a separate test that omits/unsets
SUPABASE_TEST_NO_FALLBACK (use vi.resetModules() and adjust process.env in a new
it/test block) and then import the supabase module to assert the normal fallback
path creates valid clients via the module's public factory (e.g., call the
exported createSupabaseClient/createSupabaseClients/initSupabase function or
default export) and assert non-null/expected shape for browser/server clients;
keep the existing beforeEach (vi.resetModules and process.env) for other cases
and only change env for this new positive test.
---
Duplicate comments:
In `@src/lib/__tests__/api-handler.test.ts`:
- Around line 70-87: The test suite only checks generic Error -> 500 behavior;
add tests that throw MoimError subclasses to lock in special handling: import
UnauthorizedError and ForbiddenError from "`@/lib/errors`" and add two tests that
create handlers via createApiHandler({}, async () => { throw new
UnauthorizedError(); }) and createApiHandler({}, async () => { throw new
ForbiddenError(); }), call them with a NextRequest, then assert the response
status is 401 for UnauthorizedError and 403 for ForbiddenError and verify the
JSON body contains the expected message and code fields (e.g., message "인증이
필요합니다." and code "UNAUTHORIZED" for UnauthorizedError; code "FORBIDDEN" for
ForbiddenError) to prevent regressions of MoimError mapping.
In `@src/lib/api-handler.ts`:
- Around line 49-53: Replace the direct 401 JSON return in the session check
with throwing the centralized UnauthorizedError so responses use the same
MoimError format; specifically, in the function where you check
options.requireAuth and session (the block that currently returns
NextResponse.json when no session), import/throw UnauthorizedError instead and
let apiErrorHandler (the common error handler that formats MoimError with
statusCode/code/message/details) handle the response formatting.
- Around line 37-45: The current code in api-handler.ts creates params with a
forced "{} as TParams" which hides missing required route params at compile
time; change the local variable declaration to let params: Partial<TParams> =
{}; when extracting from routeContext keep const context = routeContext as {
params: Promise<TParams> } and assign awaited value into the Partial<TParams>
variable (params = await context.params), and then update any callers of the
function (or the function's return type) to accept Partial<TParams> and
explicitly validate required fields (e.g., check for params.id) so dynamic
routes fail fast instead of producing undefined at runtime.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7d64d9dc-3f2a-4bcd-b5f1-62ba19c48b0c
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.jsonpackage.jsonis excluded by!**/*.json
📒 Files selected for processing (9)
prisma/schema.prismasrc/app/api/auth/me/route.tssrc/app/api/everytime/timetable/route.tssrc/app/api/schedules/[id]/route.tssrc/lib/__tests__/api-handler.test.tssrc/lib/api-handler.tssrc/lib/supabase/__tests__/supabase.test.tssrc/lib/supabase/env.tssrc/lib/supabase/server.ts
| if (!profile) { | ||
| throw new UnauthorizedError("프로필 정보를 찾을 수 없습니다."); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 공통 에러 클래스 정의 확인
fd 'errors.ts' src --exec sed -n '1,220p' {}
# 인증/프로필 관련 에러 사용처 확인
rg -n -C2 'UnauthorizedError\(|PROFILE_NOT_FOUND|NotFoundError' srcRepository: Siul49/moim
Length of output: 3691
🏁 Script executed:
#!/bin/bash
# errors.ts 전체에서 PROFILE_NOT_FOUND/NotFoundError 존재 여부 재확인
rg -n 'PROFILE_NOT_FOUND|NotFoundError' src/lib/errors.ts || true
wc -l src/lib/errors.tsRepository: Siul49/moim
Length of output: 76
🏁 Script executed:
#!/bin/bash
# route.ts에서 requireAuth 흐름과 profile null 처리 주변만 확인
nl -ba src/app/api/auth/me/route.ts | sed -n '1,120p'Repository: Siul49/moim
Length of output: 96
🏁 Script executed:
#!/bin/bash
cat -n src/app/api/auth/me/route.ts | sed -n '1,140p'Repository: Siul49/moim
Length of output: 1228
/api/auth/me의 profile 누락을 401로 반환하지 마세요
Why: createApiHandler({ requireAuth: true })로 이미 세션은 검증됩니다. 그런데도 profiles에 레코드가 없어서 !profile이 되는 건 인증 실패가 아니라 프로비저닝/데이터 정합성 문제라서, UnauthorizedError(401)로 내리면 프런트가 세션 만료로 오인할 수 있습니다.
How: 401 대신 도메인 에러(ProfileNotFoundError)로 분리하고, 여기서만 교체하세요.
🔧 최소 수정 예시
// src/app/api/auth/me/route.ts (19-21)
if (!profile) {
throw new ProfileNotFoundError("프로필 정보를 찾을 수 없습니다.");
}// src/lib/errors.ts
export class ProfileNotFoundError extends MoimError {
constructor(message = "프로필 정보를 찾을 수 없습니다.") {
super(message, "PROFILE_NOT_FOUND", 409, message);
}
}// src/lib/__tests__/errors.test.ts
// ProfileNotFoundError의 statusCode/code 세팅 단위 테스트 추가🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/me/route.ts` around lines 19 - 20, Replace the
UnauthorizedError thrown when profile is missing in the /api/auth/me handler
with a domain-specific ProfileNotFoundError so frontends don't treat missing
profile as an auth failure; update the check in route.ts (the if (!profile)
branch inside the createApiHandler({ requireAuth: true }) flow) to throw
ProfileNotFoundError, add the ProfileNotFoundError class extending MoimError in
src/lib/errors.ts with an appropriate code (e.g., "PROFILE_NOT_FOUND") and
status (e.g., 409), and add a unit test in src/lib/__tests__/errors.test.ts
asserting the new error's statusCode and code.
| if ( | ||
| process.env.SUPABASE_TEST_NO_FALLBACK !== "true" && | ||
| ((!options.isServer && typeof window !== "undefined") || | ||
| process.env.NODE_ENV === "test" || | ||
| process.env.VITEST) |
There was a problem hiding this comment.
isServer: true가 테스트 런타임에서 무력화됩니다.
Why: createServerClient()는 명시적으로 { isServer: true }를 넘기는데, 현재 조건식은 NODE_ENV === "test"/VITEST를 !options.isServer 바깥에서 평가합니다. 그래서 서버 경로도 예제 URL/key로 fallback 되어 환경변수 누락을 조기에 못 잡고, 각 테스트가 SUPABASE_TEST_NO_FALLBACK를 일일이 알아야 합니다.
How: test fallback도 !options.isServer 아래로 묶어서 클라이언트 경로에만 적용하세요.
코드 제안
if (
process.env.SUPABASE_TEST_NO_FALLBACK !== "true" &&
- ((!options.isServer && typeof window !== "undefined") ||
- process.env.NODE_ENV === "test" ||
- process.env.VITEST)
+ !options.isServer &&
+ (typeof window !== "undefined" ||
+ process.env.NODE_ENV === "test" ||
+ process.env.VITEST)
) {As per coding guidelines, src/lib/**: 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/env.ts` around lines 12 - 16, The current condition in
src/lib/supabase/env.ts lets NODE_ENV==="test" or process.env.VITEST trigger the
client fallback even when options.isServer===true (so createServerClient() can
be incorrectly replaced with test fallback); change the boolean expression to
ensure the test/runtime checks are evaluated only under the client branch (i.e.,
group them with !options.isServer and typeof window !== "undefined") so that
server calls (options.isServer===true) never fall back to example URL/key; keep
the outer SUPABASE_TEST_NO_FALLBACK check behavior intact and update the
condition around options.isServer in the same function where options.isServer is
read.
Source: Coding guidelines
| const isE2ETest = process.env.E2E_TEST === "true"; | ||
| const mockUid = | ||
| typeof cookieStore.get === "function" | ||
| ? cookieStore.get("e2e_mock_uid")?.value | ||
| : undefined; | ||
| const mockEmail = | ||
| typeof cookieStore.get === "function" | ||
| ? cookieStore.get("e2e_mock_email")?.value | ||
| : undefined; | ||
| const mockNickname = | ||
| (typeof cookieStore.get === "function" | ||
| ? cookieStore.get("e2e_mock_nickname")?.value | ||
| : undefined) || "e2e_user"; | ||
|
|
||
| const isMockSession = isE2ETest || (mockUid && mockEmail); | ||
|
|
||
| if (isMockSession) { |
There was a problem hiding this comment.
요청 쿠키만으로 E2E mock 세션이 활성화됩니다.
Why: 지금은 E2E_TEST가 꺼져 있어도 요청에 e2e_mock_uid와 e2e_mock_email 쿠키만 실리면 mock client로 갈아탑니다. 이러면 임의 쿠키를 심은 요청이 실제 Auth/DB를 우회하므로, 배포 환경에서도 인증 우회가 가능합니다.
How: mock 모드 진입은 서버 측 플래그로만 허용하고, 쿠키 값은 그 안에서 mock 사용자 데이터로만 사용하세요.
코드 제안
- const isMockSession = isE2ETest || (mockUid && mockEmail);
+ const isMockSession = isE2ETest;As per coding guidelines, src/lib/**: 보안 취약점(OWASP Top 10), 메모리 누수 가능성에 주의하세요.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const isE2ETest = process.env.E2E_TEST === "true"; | |
| const mockUid = | |
| typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_uid")?.value | |
| : undefined; | |
| const mockEmail = | |
| typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_email")?.value | |
| : undefined; | |
| const mockNickname = | |
| (typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_nickname")?.value | |
| : undefined) || "e2e_user"; | |
| const isMockSession = isE2ETest || (mockUid && mockEmail); | |
| if (isMockSession) { | |
| const isE2ETest = process.env.E2E_TEST === "true"; | |
| const mockUid = | |
| typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_uid")?.value | |
| : undefined; | |
| const mockEmail = | |
| typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_email")?.value | |
| : undefined; | |
| const mockNickname = | |
| (typeof cookieStore.get === "function" | |
| ? cookieStore.get("e2e_mock_nickname")?.value | |
| : undefined) || "e2e_user"; | |
| const isMockSession = isE2ETest; | |
| if (isMockSession) { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 15 - 31, The code currently allows
mock sessions whenever e2e_mock_uid and e2e_mock_email cookies are present;
restrict mock mode to the server-side flag only by changing the control flow so
isE2ETest is required before cookies can enable a mock session. Concretely,
ensure isMockSession is only true when isE2ETest is true (e.g. compute
isMockSession = isE2ETest && (mockUid && mockEmail)), and only use mockUid,
mockEmail, mockNickname to populate a mock user after verifying isE2ETest;
update the branch that currently checks isMockSession so cookies alone cannot
trigger the mock client.
Source: Coding guidelines
| updateUser: async ({ data }: { data: any }) => { | ||
| return { | ||
| data: { | ||
| user: { | ||
| id: mockUid || "e2e_default_uid", | ||
| email: mockEmail || "e2e_default_email@example.com", | ||
| user_metadata: { ...data, nickname: mockNickname }, | ||
| }, |
There was a problem hiding this comment.
updateUser mock이 전달된 nickname을 항상 덮어씁니다.
Why: user_metadata: { ...data, nickname: mockNickname } 순서 때문에 호출자가 data.nickname을 보내도 응답에는 기존 mockNickname만 남습니다. 프로필 완료/닉네임 변경 흐름이 실제 Supabase와 다른 계약 위에서 검증됩니다.
How: 입력값을 우선 보존하고, nickname이 없을 때만 fallback 하세요.
코드 제안
updateUser: async ({ data }: { data: any }) => {
+ const nextMetadata = { ...data };
+ if (nextMetadata.nickname === undefined) {
+ nextMetadata.nickname = mockNickname;
+ }
+
return {
data: {
user: {
id: mockUid || "e2e_default_uid",
email: mockEmail || "e2e_default_email@example.com",
- user_metadata: { ...data, nickname: mockNickname },
+ user_metadata: nextMetadata,
},
},
error: null,
};
},As per coding guidelines, src/lib/**: 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/supabase/server.ts` around lines 105 - 112, The mock updateUser
currently always overwrites caller-provided nickname because it builds
user_metadata as { ...data, nickname: mockNickname }; change this to preserve an
incoming nickname and only fallback to mockNickname when data.nickname is
missing or empty (e.g., use nickname: data?.nickname ?? mockNickname or spread
with nickname last only when absent). Update the updateUser mock to check
data.nickname (and handle undefined/null/empty) and return user_metadata that
respects the caller's value, keeping typesafe checks around data before
accessing nickname.
Source: Coding guidelines
🚀 작업 내용 (What)
📣 핵심 변경 이유 (Why)
📸 스크린샷 (Visuals, 선택)
🔗 관련 이슈 (Issue)
Close #62