Skip to content

refactor: API 엔드포인트 SRP 및 DRY 리팩토링 (#62) - #63

Merged
Siul49 merged 10 commits into
devfrom
feature/62-api-refactoring
Jun 12, 2026
Merged

refactor: API 엔드포인트 SRP 및 DRY 리팩토링 (#62)#63
Siul49 merged 10 commits into
devfrom
feature/62-api-refactoring

Conversation

@Siul49

@Siul49 Siul49 commented Jun 12, 2026

Copy link
Copy Markdown
Owner

🚀 작업 내용 (What)

  • Next.js API 엔드포인트 내에 혼재되어 있던 인증(Session) 확인, 바디 파싱, Zod 유효성 검사, 에러 제어 및 HTTP 응답 구성을 공통 래퍼(ApiHandler)로 추상화하여 중복을 배제했습니다.
  • /api/schedules/, /api/auth/\ (me, signup, login, logout, forgot-password), /api/google/\ (calendars, query, create, disconnect), /api/icloud/\ (calendars, connect, disconnect, query, create), /api/everytime/*\ (timetable) 전체 API 엔드포인트를 공통 래퍼 기반으로 마이그레이션했습니다.
  • 비밀번호 찾기 이메일 검증용 Zod 스키마(\ orgot-password.schema.ts)를 신규 정의했습니다.

📣 핵심 변경 이유 (Why)

  • 각 API 라우터에 흩어져 있던 세션 검증, 바디 파싱, 예외 구조의 중복 코드를 제거(DRY)하고, 라우터 컨트롤러가 비즈니스 쿼리에만 집중(SRP)할 수 있도록 구조를 개선했습니다.

📸 스크린샷 (Visuals, 선택)

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

🔗 관련 이슈 (Issue)

Close #62

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moim-app Ready Ready Preview, Comment Jun 12, 2026 12:19pm

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

Release Notes

  • New Features

    • 통합 API 핸들러로 인증, 요청 검증, 에러 처리 통합
    • 계층적 에러 시스템 도입으로 API 응답 일관성 개선
    • Naver 로그인 통합 개선
  • Bug Fixes

    • 비밀번호 검증 로직 강화 및 일관성 개선
  • Chores

    • 로컬 개발 서버 포트 변경 (3000 → 4000)
    • 달력 어댑터 아키텍처 리팩토링
    • API 에러 처리 표준화

Walkthrough

공통 API 핸들러, 중앙 에러 계층, Zod 기반 검증 스키마를 도입하여 40개 이상 라우트의 중복 제거. OAuth origin 처리 통합과 캘린더 어댑터 클래스화로 외부 연동과 데이터 변환 계약을 표준화합니다.

Changes

API 핸들러 및 외부 연동 통합

Layer / File(s) Summary
공통 에러 계층 정리
src/lib/errors.ts, src/lib/auth/session.ts, src/lib/caldav/client.ts, src/lib/everytime/auth.ts, src/lib/everytime/timetable.ts, src/lib/everytime/url-scraper.ts, src/lib/__tests__/errors.test.ts
MoimError 베이스 클래스와 UnauthorizedError, ForbiddenError, ExternalServiceError, CalDAVError, EverytimeError 하위 클래스들을 중앙화하고, 기존 파일들의 로컬 에러 정의를 재-export로 교체하며 테스트로 전체 매핑을 검증합니다.
공통 API 핸들러 구현 및 테스트
src/lib/api-handler.ts, src/lib/__tests__/api-handler.test.ts
createApiHandler가 세션 조회(requireAuth), 바디 파싱, Zod 검증(bodySchema), 라우트 params 추출, MoimError 매핑을 공통으로 처리하며, 인증 실패·파싱 실패·검증 실패·예외 시 적절한 HTTP 상태와 응답 형식을 제공합니다. 12개 테스트 케이스가 모든 흐름을 검증합니다.
인증 입력 검증 스키마 통합
src/features/auth/password.schema.ts, src/features/auth/forgot-password.schema.ts, src/features/auth/signup.schema.ts, src/features/auth/__tests__/password.schema.test.ts
비밀번호 규격(PASSWORD_REGEX: 8자 이상, 영문·숫자·특수문자 포함), Zod passwordSchema, validatePassword() 헬퍼를 통합하고, signupSchemaforgotPasswordSchema가 이를 재사용하도록 정리하며 모든 케이스를 테스트합니다.
인증 및 프로필 API 라우트 공통화
src/app/api/auth/signup/route.ts, src/app/api/auth/login/route.ts, src/app/api/auth/logout/route.ts, src/app/api/auth/me/route.ts, src/app/api/auth/forgot-password/route.ts, src/app/api/auth/reset-password/complete/route.ts, src/app/api/auth/profile/complete/route.ts, src/app/(auth)/signup/page.tsx, src/app/(auth)/reset-password/complete/page.tsx, src/app/api/auth/naver/login/route.ts, src/app/api/auth/naver/callback/route.ts
회원가입, 로그인, 로그아웃, 내 정보, 포그오트 비밀번호, 비밀번호 완료, 프로필 완료 라우트가 createApiHandler + 바디 스키마 기반으로 통일되며, 프론트엔드 폼 검증도 validatePassword 헬퍼를 사용합니다. Naver OAuth에 origin 파라미터 전달이 추가됩니다.
스케줄 검증 스키마 및 저장소 설정
src/features/schedules/schedule.schema.ts, src/lib/supabase/env.ts, src/lib/supabase/__tests__/supabase.test.ts, prisma/schema.prisma, .env.example
요일·시간 슬롯(dayCodeSchema, timeSlotSchema), 스케줄 생성·확정 검증(createScheduleSchema, confirmScheduleSchema)을 Zod로 정의하고, Supabase 환경 설정과 Prisma 스키마가 로컬 개발(SQLite, mock 세션)과 프로덕션을 모두 지원하며, localhost 포트가 3000에서 4000으로 변경됩니다.
스케줄 API 라우트 공통화
src/app/api/schedules/route.ts, src/app/api/schedules/[id]/route.ts
POST(생성), GET(조회), PATCH(확정) 라우트가 createApiHandler 기반으로 전환되며, 세션 기반 작성자 조회와 hostToken 기반 호스트 조회 분기, 토큰 쿠키 저장/무효화 로직이 유지됩니다.
OAuth 리다이렉트 URI origin 통합
src/lib/google/auth.ts, src/lib/google/__tests__/auth.test.ts, src/lib/auth/naver.ts, src/lib/auth/__tests__/naver.test.ts, src/app/api/google/auth/route.ts, src/app/api/google/callback/route.ts, src/app/api/auth/naver/login/route.ts, src/app/api/auth/naver/callback/route.ts
Google과 Naver OAuth가 요청 origin → 환경변수 → NEXT_PUBLIC_BASE_URL → 기본값(localhost:4000) 우선순위로 리다이렉트 URI를 계산하도록 정리되며, 각 서비스 토큰 교환 함수가 origin 파라미터를 받습니다. 테스트로 origin 기반 콜백 생성과 환경변수 우선 사용을 검증합니다.
외부 연동 API 라우트 공통화
src/app/api/google/calendars/route.ts, src/app/api/google/disconnect/route.ts, src/app/api/google/events/create/route.ts, src/app/api/google/events/query/route.ts, src/app/api/icloud/calendars/route.ts, src/app/api/icloud/connect/route.ts, src/app/api/icloud/disconnect/route.ts, src/app/api/icloud/events/create/route.ts, src/app/api/icloud/events/query/route.ts, src/app/api/everytime/timetable/route.ts
Google/iCloud/Everytime 캘린더 조회, 연결, 일정 생성·조회 라우트가 createApiHandler 기반으로 전환되며, 각 서비스별 에러 상태(401 인증 만료, 409 중복, 502 연동 실패, 500 예외)와 로깅이 유지됩니다.
캘린더 어댑터 클래스 기반 구조화
src/lib/calendar/adapter.ts, src/lib/calendar/adapters/google.ts, src/lib/calendar/adapters/icloud.ts, src/lib/calendar/adapters/manual.ts, src/lib/calendar/adapters/photo.ts, src/lib/calendar/adapters/__tests__/manual.test.ts
공통 BaseCalendarAdapter<TRaw, TItem> 추상 클래스와 ArrayCalendarAdapter<TItem> 템플릿을 도입하고, Google/iCloud/수동 입력/사진 추출 어댑터가 각각 getExternalId/getTitle/getStartAt/getEndAt/getIsAllDay 메서드를 구현하는 클래스로 재구성됩니다. 제목 기본값이 빈 문자열로 통일되고 테스트도 추가됩니다.
로컬 개발 및 E2E 테스트 보강
src/lib/supabase/server.ts
Supabase 클라이언트가 E2E 쿠키(e2e_mock_uid) 또는 E2E_TEST 환경변수 기반 mock 세션을 인식하여 MockSupabaseQueryBuilder Proxy로 전환되며, Naver 사용자 조회 시 ID 접두어(naver_) 처리가 추가되고 로컬 환경값 fallback 조건이 정교해집니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Why: 40개 이상 라우트의 핸들러 시그니처 변경과 공통 래퍼 도입으로 이해해야 할 제어 흐름이 많으며, 에러 계층 중앙화와 어댑터 클래스화는 기존 코드와의 호환성을 확인해야 합니다.

How: (1) createApiHandler 코어 흐름과 테스트 12개를 먼저 검증하여 공통 패턴 이해. (2) 인증 라우트 2~3개를 샘플로 읽어 requireAuth: true/false, bodySchema, 예외 처리 매핑 확인. (3) OAuth origin 우선순위 로직과 테스트로 환경별 동작 검증. (4) 어댑터 클래스 계층과 메서드 추상화가 기존 호출 사이트와 맞는지 확인. (5) 로컬 개발 mock 경로가 E2E 테스트와 일관성 있는지 검토.

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

  • Siul49/moim#61: 캘린더 어댑터 클래스 기반 리팩토링과 공통 에러 계층이 현재 PR과 동일하게 적용되어 병렬 진행 또는 의존성 확인 필요.
  • Siul49/moim#21: Everytime 시간표 라우트 기능 구현이 현재 PR의 createApiHandler 전환 대상이므로 순서 조정 고려.

Suggested labels

refactor, test

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/62-api-refactoring

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 헤더 파싱은 핸들러 내부에서 처리하거나, createApiHandlercustomAuth 옵션 추가.

♻️ 제안 구조 (스키마 추가 + 핸들러 전환)

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 lift

createApiHandler 사용 목적과 불일치 - 불완전한 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 스키마로 쿼리 파라미터를 정의하고, createApiHandlerquerySchema 옵션을 추가하거나, 핸들러 내부에서 스키마 검증을 추상화하세요.

♻️ 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 win

OAuth 콜백에서 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 win

Google 캘린더 라우트 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.tsrequireAuth: 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

📥 Commits

Reviewing files that changed from the base of the PR and between e3d3e5b and b5bb53c.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (56)
  • .env.example
  • e2e/host-flow.spec.ts
  • prisma/schema.prisma
  • scripts/ensure-sqlite-schema.mjs
  • src/app/(auth)/reset-password/complete/page.tsx
  • src/app/(auth)/signup/page.tsx
  • src/app/api/auth/forgot-password/route.ts
  • src/app/api/auth/login/route.ts
  • src/app/api/auth/logout/route.ts
  • src/app/api/auth/me/route.ts
  • src/app/api/auth/naver/callback/route.ts
  • src/app/api/auth/naver/login/route.ts
  • src/app/api/auth/profile/complete/route.ts
  • src/app/api/auth/reset-password/complete/route.ts
  • src/app/api/auth/signup/route.ts
  • src/app/api/everytime/timetable/route.ts
  • src/app/api/google/auth/route.ts
  • src/app/api/google/calendars/route.ts
  • src/app/api/google/callback/route.ts
  • src/app/api/google/disconnect/route.ts
  • src/app/api/google/events/create/route.ts
  • src/app/api/google/events/query/route.ts
  • src/app/api/icloud/calendars/route.ts
  • src/app/api/icloud/connect/route.ts
  • src/app/api/icloud/disconnect/route.ts
  • src/app/api/icloud/events/create/route.ts
  • src/app/api/icloud/events/query/route.ts
  • src/app/api/schedules/[id]/route.ts
  • src/app/api/schedules/route.ts
  • src/features/auth/__tests__/password.schema.test.ts
  • src/features/auth/forgot-password.schema.ts
  • src/features/auth/password.schema.ts
  • src/features/auth/signup.schema.ts
  • src/features/schedules/schedule.schema.ts
  • src/lib/__tests__/api-handler.test.ts
  • src/lib/__tests__/errors.test.ts
  • src/lib/api-handler.ts
  • src/lib/auth/__tests__/naver.test.ts
  • src/lib/auth/naver.ts
  • src/lib/auth/session.ts
  • src/lib/caldav/client.ts
  • src/lib/calendar/adapter.ts
  • src/lib/calendar/adapters/__tests__/manual.test.ts
  • src/lib/calendar/adapters/google.ts
  • src/lib/calendar/adapters/icloud.ts
  • src/lib/calendar/adapters/manual.ts
  • src/lib/calendar/adapters/photo.ts
  • src/lib/errors.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts
  • src/lib/google/__tests__/auth.test.ts
  • src/lib/google/auth.ts
  • src/lib/supabase/client.ts
  • src/lib/supabase/env.ts
  • src/lib/supabase/server.ts

Comment thread .env.example
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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_here

Line 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/callback

Also 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

Comment thread e2e/host-flow.spec.ts
Comment on lines +39 to +54
// 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!");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment thread prisma/schema.prisma Outdated
{
bodySchema: loginSchema,
},
async ({ body }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

핸들러 콜백 파라미터 타입 미명시로 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread src/lib/supabase/server.ts Outdated
Comment on lines +100 to +138
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,
};
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment on lines +192 to +194
async execute() {
if (this.table === "profiles") {
const { prisma } = await import("@/lib/prisma");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +200 to +217
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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.

Comment on lines +220 to +235
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,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -A2

Repository: 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/ -S

Repository: 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/ -S

Repository: 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 -S

Repository: 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/ -S

Repository: 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 -S

Repository: 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 -n

Repository: 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/null

Repository: 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:


업서트 충돌 키 로직: 현재 경로는 결정적이지만, Mock의 fallback은 Supabase 동작과 의미가 어긋날 수 있음

  • src/app/dashboard/settings/page.tsxprofiles 업서트는 payload에 id: user.id를 항상 포함하고(검출된 사용처) eq('id', ...) 체인을 같이 쓰지 않아, src/lib/supabase/server.tslookupId = 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.

Comment on lines +309 to +315
maybeSingle() {
return this;
}

single() {
return this;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

maybeSinglesingle의 동작 차이 미구현.

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.

Suggested change
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +10 to 14
export async function GET(request: Request) {
const { origin } = new URL(request.url);
const authUrl = buildAuthUrl(undefined, origin);
return NextResponse.redirect(authUrl);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: callback route에서 state 파라미터 검증 확인

rg -n -A 10 'searchParams.get\("state"\)' src/app/api/google/callback/route.ts

Repository: 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.

Comment on lines +22 to 28
export const POST = createApiHandler(
{
bodySchema: ConnectSchema,
},
async ({ body }) => {
const { appleId, appPassword } = body;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 3

Repository: 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 || true

Repository: 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
fi

Repository: 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 -S

Repository: 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
done

Repository: Siul49/moim

Length of output: 19948


icloud/connect를 포함한 iCloud API 라우트에 인증/CSRF 방어 필요

createApiHandlerrequireAuth: 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.

Comment on lines +7 to +10
export const POST = createApiHandler({}, async () => {
await clearConnection();
return NextResponse.json({ success: true });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 3

Repository: 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 || true

Repository: 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"
done

Repository: Siul49/moim

Length of output: 3549


[보안] /api/icloud/disconnect requireAuth 누락(세션 없는 쿠키 삭제 가능)

Why: createApiHandleroptions.requireAuth일 때만 getSession()으로 인증을 검사하는데, iCloud disconnectcreateApiHandler({}, ...)로 세션 검증 없이 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.

Comment on lines +56 to +84
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",
);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

토큰 교환 실패 케이스 추가 필요.

성공 경로만 검증하고 있으나, 실제 구현에는 !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.

Comment thread src/lib/google/auth.ts
Comment on lines +37 to 44
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`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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는 이제 MoimErrorstatusCode/code/message/details를 표준 응답으로 내려주는데, 여기만 직접 401 JSON을 반환해서 인증 실패 응답 모양이 다른 예외 경로와 달라집니다. 클라이언트는 같은 인증 오류를 두 가지 포맷으로 처리해야 합니다.

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: handlerMoimError 하위 클래스를 던지는 케이스를 추가해 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

📥 Commits

Reviewing files that changed from the base of the PR and between b5bb53c and 3f466c7.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (9)
  • prisma/schema.prisma
  • src/app/api/auth/me/route.ts
  • src/app/api/everytime/timetable/route.ts
  • src/app/api/schedules/[id]/route.ts
  • src/lib/__tests__/api-handler.test.ts
  • src/lib/api-handler.ts
  • src/lib/supabase/__tests__/supabase.test.ts
  • src/lib/supabase/env.ts
  • src/lib/supabase/server.ts

Comment on lines +19 to +20
if (!profile) {
throw new UnauthorizedError("프로필 정보를 찾을 수 없습니다.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 공통 에러 클래스 정의 확인
fd 'errors.ts' src --exec sed -n '1,220p' {}

# 인증/프로필 관련 에러 사용처 확인
rg -n -C2 'UnauthorizedError\(|PROFILE_NOT_FOUND|NotFoundError' src

Repository: 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.ts

Repository: 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/meprofile 누락을 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.

Comment thread src/lib/supabase/env.ts
Comment on lines +12 to +16
if (
process.env.SUPABASE_TEST_NO_FALLBACK !== "true" &&
((!options.isServer && typeof window !== "undefined") ||
process.env.NODE_ENV === "test" ||
process.env.VITEST)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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

Comment on lines +15 to +31
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

요청 쿠키만으로 E2E mock 세션이 활성화됩니다.

Why: 지금은 E2E_TEST가 꺼져 있어도 요청에 e2e_mock_uide2e_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.

Suggested change
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

Comment on lines +105 to +112
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 },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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

@Siul49
Siul49 merged commit 1d59b0d into dev Jun 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] API 엔드포인트 SRP 및 DRY 리팩토링

1 participant