VIBE / CODING이 일차 자료 목차로 돌아가기

DAY 03 / SOURCE FILES

예제 코드 · 읽고 비교하기

코드는 ZIP 원본과 아래 열람본으로 함께 제공합니다.

3~5일차 앱 실행에는 Node 개발 환경·의존성·Supabase와 외부 인증 설정이 필요합니다. 여기서는 코드와 SQL을 오프라인으로 읽고 ZIP을 저장할 수 있습니다.

파일 선택

assignment-check-day3/README.md
3일차 과제 체크 앱

starter는2일차단일HTML, solution은Vite+Supabase완성프로젝트입니다.
solution안의파일을본인clone저장소루트에넣고.git은유지합니다. .env.example을.env.local로복사해수업프로젝트의공개URL과publishable key를설정합니다. npm ci→npm run dev. SQL은새수업DB에만적용합니다.

학생8단계와강사설명: https://vibe-coding-course-three.vercel.app/assignment-day3-lab.html
키·로그인정보·node_modules·dist는포함하지않았습니다. 실제클라우드가입메일/공개앱검사는수업계정에서진행합니다.
assignment-check-day3/checks.csv
검사,행동,기대결과,통과/실패/미확인,실제결과·확인자
설정,URL/publishable key·.env.local 제외,공개2변수만 사용·비밀키없음,,
스키마,setup.sql/inspect.sql,RLS·소유자4정책·명시GRANT,,
가입,이메일/12자비밀번호로가입,확인대기·실제메일·확인구분,,
로그인,잘못된암호/정상로그인/새로고침,오류안내·정상세션복원,,
추가/조회,A의가상과제추가→재조회,DB동일행·고유id,,
수정/취소,완료한과제수정→취소검사,id/done/created_at유지·취소불변,,
기존기능,검색/필터/날짜/삭제·되돌리기,현재계정목록기준정상,,
기기간,다른브라우저의A로그인후조회,같은DB목록,,
A/B조회,B로A id Data API 조회,0건,,
A/B쓰기,B로A id 수정·삭제,0건또는거절·A행불변,,
소유자위조,B가user_id=A로INSERT/소유자변경,거절,,
미로그인,로그아웃화면·익명Data API,목록즉시비움·DB차단,,
비동기경합,A요청중B전환,늦은A응답표시없음,,
네트워크오류,DB접속실패/미저장폼,성공표시없음·입력유지,,
공개배포,Production URL/commit/환경,최신빌드·설정일치,,
공개메일,가입메일확인후공개앱복귀,허용URL·메일·로그인완료,,
assignment-check-day3/prompts.txt
schemaPrompt
2일차 과제 체크 앱과 현재 Git 브랜치·diff를 먼저 읽어줘. 기존 HTML/CSS·수정·검색·완료·집계를 유지하면서 Vite와 @supabase/supabase-js로 구성해줘. 현재 공식 문서와 설치 버전의 Node 요구를 확인하고 패키지 버전을 고정해 lockfile을 남겨줘. DB는 새 수업용 Supabase의 public.tasks에 id uuid, user_id(auth.users 참조), title(공백만 불가·60자), subject(20자), due(date), done, created_at을 둬. user_id·due 인덱스, RLS, authenticated의 최소 GRANT와 본인 user_id=auth.uid() 조건의 SELECT/INSERT/UPDATE/DELETE 정책을 SQL 파일로 만들어줘. UPDATE는 USING과 WITH CHECK를 모두 쓰고 소유자·id·생성시각은 수정하지 못하게 해줘. anon은 데이터에 접근하지 못하게 해줘. 공개 URL과 publishable key만 VITE_SUPABASE_URL/VITE_SUPABASE_PUBLISHABLE_KEY로 읽고 .env.example과 .gitignore를 만들어줘. secret/service_role·DB 비밀번호는 브라우저나 Git에 넣지 마. 기존 운영 DB에 SQL을 실행하지 말고 새 수업 프로젝트에서 적용할 SQL과 검증 절차를 보여줘.

authPrompt
현재 앱에 Supabase 이메일 회원가입·확인 대기·로그인·로그아웃과 회원별 과제 CRUD를 연결해줘. 가입 응답에서 session이 없으면 메일 확인 대기로 표시하고 로그인 성공과 구분해줘. 단일 브라우저 앱은 PKCE·현재 루트 복귀 주소·SDK 세션 복원을 일관되게 사용하고 허용 Redirect URL을 안내해줘. 가입 new-password/로그인 current-password, label·POST form·비밀번호 표시·12자 가입 규칙·중복 제출 방지를 넣어줘. 비밀번호·토큰·계정 존재 여부를 로그나 오류에 노출하지 마. 로그인 전 목록을 숨기고 계정 변경 즉시 이전 목록·편집·되돌리기를 지워줘. 이전 계정의 늦은 요청 응답도 무시해줘. DB select/insert/update/delete의 error와 반환 행을 확인한 뒤만 성공을 표시해줘. 수정은 동일 id·완료·생성시각을 보존하고 삭제 되돌리기도 DB에 반영해줘. 검색·완료 필터·날짜 집계를 유지하며 다른 기기 변경은 새로 불러오기로 조회해줘. localStorage의 전날 과제를 자동 업로드하지 말고 가상 DB 과제로 시작해줘. 네트워크 실패 때 미저장 입력을 유지해줘. 실제 실행 결과와 미실행을 구분해줘.

deployPrompt
이 과제 앱을 학생 본인 GitHub 저장소에서 Vercel로 배포할 절차를 준비해줘. 현재 경로·브랜치·remote, package.json·lockfile·build를 확인해줘. Vercel Root Directory는 실제 앱 위치, Framework는 Vite, Build는 npm run build, Output은 dist로 맞추고 두 VITE 공개 변수의 Production/필요한 Preview 범위를 설명해줘. .env.local·키·토큰은 commit하지 마. Supabase Site URL은 실제 고정 Production 루트, Redirect URLs는 로컬5173/와 정확한 공개 루트로 맞추고 환경값 변경 뒤 재빌드하도록 안내해줘. PR diff·검증표·병합·배포 SHA를 남겨줘. 가입 요청/메일 도착/확인/로그인/DB 저장/재조회/다른 브라우저 동일계정 복원/A·B 분리/미로그인 차단을 따로 검사해줘. RLS 실패를 UI 숨김·RLS 해제·service_role 노출로 우회하지 마. 원격 변경과 SQL 적용은 수업 단계에서 대상 프로젝트를 확인한 뒤 실행하게 해줘.

verifyPrompt
수업용 Supabase에서 계정 A와 B를 만들어 실제 가입 메일·로그인을 확인해줘. A가 만든 과제를 새로고침과 다른 브라우저의 A 로그인으로 다시 읽고 수정·완료·삭제·되돌리기를 검사해줘. B에서는 A 행 조회0건, A id를 지정한 수정·삭제0건 또는 거절, user_id=A로 위조INSERT 거절, 소유자변경 거절을 Data API 요청으로 검사해줘. 미로그인 요청도 차단되는지 확인해줘. SQL Editor 관리자 조회를 RLS 통과 증거로 쓰지 마. 계정 전환 직후와 늦은 이전 요청 응답에서 A 목록이 B 화면에 나타나지 않는지, 네트워크 실패가 저장 성공으로 표시되지 않는지 확인해줘. 공개 Vercel URL의 배포commit·환경·가입복귀URL도 대조하고 결과를 통과/실패/미확인으로 기록해줘. 이메일·비밀번호·토큰은 보고서에 넣지 마.
assignment-check-day3/solution/.env.example
VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REPLACE_WITH_YOUR_KEY
assignment-check-day3/solution/.gitignore
node_modules/
dist/
.env
.env.*
!.env.example
.vercel/
assignment-check-day3/solution/README.md
# 과제 체크 앱 · 3일차 완성 코드

전날 HTML/CSS·수정·검색을 유지하고 Vite + Supabase JS로 계정별 DB 저장을 연결합니다.

1. 본인의 새 수업용 Supabase 프로젝트에 `sql/setup.sql`을 한 번 실행합니다. 기존 tasks가 있으면 먼저 구조와 권한을 비교합니다.
2. Auth Email 가입과 Confirm email을 켜고 최소 비밀번호12자 정책을 맞춥니다. 발송 대상·한도를 확인하고 필요하면 강사가 사전 준비한 Resend SMTP를 연결합니다.
3. `.env.example`을 `.env.local`로 복사해 본인 프로젝트의 URL과 publishable key를 입력합니다. `.env.local`은 Git에 올리지 않습니다. VITE_ 변수는 브라우저에 공개됩니다. secret/service_role/DB 비밀번호는 넣지 않습니다.
4. Node22.12이상, `npm ci`, `npm run dev`. 실제 로컬 주소(기본127.0.0.1:5173/)를 Supabase Site URL/Redirect URLs와 맞춥니다. localhost와127.0.0.1은 다른 주소입니다.
5. 수업용 가상 계정으로 가입·메일 확인·로그인·CRUD·새로고침·계정 A/B 권한을 확인합니다. 전날 브라우저 목록을 자동 업로드하지 않습니다.
6. `npm run build` 후 GitHub에 소스·SQL·lockfile·환경예시를 commit/push하고 PR로 main에 병합합니다.
7. Vercel에 본인 저장소 Import: 실제 앱 폴더, Vite, npm run build, dist. 두 VITE 공개 변수를 Production/필요Preview에 등록합니다. Vercel이 DB SQL을 자동 적용하지 않습니다.
8. Supabase Site URL은 실제 고정 Production 루트, Redirect URLs는 정확한 로컬/공개/필요Preview 루트로 설정합니다. 환경값 변경 뒤 재빌드하고 공개 앱에서 다시 검증합니다.

PKCE 이메일 확인 링크는 요청한 같은 브라우저에서 여세요. 다른 브라우저에서 확인했다면 확인 완료 후 원래 앱에서 비밀번호로 로그인할 수 있습니다. 비밀번호 복구 메일/새 비밀번호 설정도 보충 기능으로 제공합니다. Auth 토큰·비밀번호는 로그나 과제물에 남기지 않습니다.

`src/task-store.js`는 반환 행과 오류를 확인합니다. `src/main.js`는 계정 전환 즉시 목록을 비우고 이전 계정의 늦은 응답을 무시합니다. 검색은 현재 계정에서 읽은 최대1,000건에 적용합니다. 다른 기기 변경은 새로 불러오기로 조회하며 Realtime을 사용하지 않습니다.

RLS 검사는 SQL Editor 관리자 조회로 대체하지 않습니다. 계정 B의 실제 클라이언트로 A id 조회0건·수정/삭제0건 또는 거절·user_id 위조INSERT 거절을 확인합니다. 미로그인도 차단되어야 합니다. 수업용 가상 데이터에서만 수행하세요.

전체 학생 절차·Codex 요청·강사 설명: https://vibe-coding-course-three.vercel.app/assignment-day3-lab.html
assignment-check-day3/solution/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
<title>나의 과제, 어디서든 · 과제 체크 3일차</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-day3-lab.html">3일차 실습 교재 ↗</a></header>
<main>
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 03 / YOUR TASKS, ANYWHERE</p><h1 id="pageTitle">나의 과제를,<br>어디서든 가볍게.</h1><p>로그인한 계정의 과제를 저장하고, 다른 기기에서도 이어보세요.</p></div><span class="badge" id="storageBadge">계정에 연결된 DB</span></section>

<div id="configNotice" class="config" role="status" hidden>연결 설정이 필요해요. .env.local에 수업용 Supabase URL과 publishable key를 넣고 개발 서버를 다시 시작하세요. 비밀키는 사용하지 않습니다.</div>
<section id="authArea" aria-label="계정 로그인과 가입">
<div class="auth-grid">
<section class="panel"><form id="loginForm" action="#authArea" method="post"><fieldset disabled><legend>다시 만나 반가워요.</legend>
<div class="field"><label for="loginEmail">이메일 · 필수</label><input id="loginEmail" name="email" type="email" autocomplete="username" spellcheck="false" autocapitalize="off" required></div>
<div class="field"><label for="loginPassword">비밀번호 · 필수</label><div class="password-row"><input id="loginPassword" name="password" type="password" autocomplete="current-password" required><button type="button" data-show="loginPassword" aria-pressed="false">표시</button></div></div>
<button class="primary" type="submit">로그인</button><button class="helper-button" id="resetPassword" type="button">비밀번호 재설정 메일 받기</button>
</fieldset></form></section>
<section class="panel"><form id="signupForm" action="#authArea" method="post"><fieldset disabled><legend>처음이라면, 회원가입.</legend>
<div class="field"><label for="signupEmail">이메일 · 필수</label><input id="signupEmail" name="email" type="email" autocomplete="username" spellcheck="false" autocapitalize="off" required></div>
<div class="field"><label for="signupPassword">새 비밀번호 · 필수</label><p class="hint" id="passwordHint">12자 이상. 다른 사이트와 다른 비밀번호를 사용하세요.</p><div class="password-row"><input id="signupPassword" name="password" type="password" minlength="12" autocomplete="new-password" aria-describedby="passwordHint" required><button type="button" data-show="signupPassword" aria-pressed="false">표시</button></div></div>
<button class="primary" type="submit">회원가입</button><p class="auth-note">가입 확인 메일을 연 뒤 로그인해 주세요.</p>
</fieldset></form></section>
</div></section>
<p id="authMessage" class="auth-status" role="status" aria-live="polite">계정 상태를 확인하고 있어요.</p>
<div id="accountArea" class="account" hidden><span id="accountEmail"></span><button id="logoutBtn" type="button">로그아웃</button></div>
<section id="recoveryArea" class="panel" hidden><form id="recoveryForm" action="#recoveryArea" method="post"><h2>새 비밀번호 설정</h2><div class="field"><label for="recoveryPassword">새 비밀번호 · 12자 이상</label><input id="recoveryPassword" name="password" type="password" minlength="12" autocomplete="new-password" required></div><button type="submit">새 비밀번호 저장</button></form></section>
<div id="appArea" hidden>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
<section class="panel" aria-labelledby="newTaskTitle"><h2 id="newTaskTitle">새 과제 추가</h2><p class="intro">작게 적어두면, 시작하기 쉬워져요.</p>
<noscript><p>이 예제 앱은 JavaScript가 필요합니다. 브라우저에서 JavaScript를 켠 뒤 다시 열어주세요.</p></noscript>
<form id="taskForm" action="#newTaskTitle" method="post"><fieldset id="inputFields" disabled style="border:0;padding:0;margin:0;min-width:0"><legend style="position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden">과제 정보</legend>
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
<button class="primary" id="submitTask" type="submit">+ 과제 추가</button>
<button id="cancelEdit" class="cancel" type="button" hidden>수정 취소</button>
</fieldset></form>
<p class="privacy">이 계정의 과제를 DB에 저장해요. 다른 기기에서 같은 계정으로 로그인한 뒤 다시 불러올 수 있어요. 전날 브라우저 목록은 자동 업로드하지 않아요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button id="refreshBtn" type="button">새로 불러오기</button><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
<div class="search-field"><label for="taskSearch">제목·과목 검색</label><div class="search-row"><input id="taskSearch" type="search" placeholder="예: 감상문 또는 국어" autocomplete="off" aria-controls="tasks"><button id="clearSearch" type="button" disabled>지우기</button></div></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
<p class="list-foot" id="listSummary" role="status"></p>
<div class="notice"><span id="status" role="status" aria-live="polite"></span><button id="undoBtn" type="button" hidden>삭제 되돌리기</button></div>
</section>
</div>
</div>
</main>
<footer><p>DAY 03 · Supabase Auth + PostgreSQL → Vercel</p><p>본인 계정의 과제만 표시됩니다. 새로 불러오기로 최신 목록을 확인하세요.</p></footer>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
assignment-check-day3/solution/package-lock.json
{
  "name": "assignment-check-day3",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "assignment-check-day3",
      "version": "1.0.0",
      "dependencies": {
        "@supabase/supabase-js": "2.119.0"
      },
      "devDependencies": {
        "vite": "8.3.7"
      },
      "engines": {
        "node": ">=22.12.0"
      }
    },
    "node_modules/@oxc-project/types": {
      "version": "0.156.0",
      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
      "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/oxc-project"
      }
    },
    "node_modules/@rolldown/binding-android-arm-eabi": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.18.tgz",
      "integrity": "sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-android-arm64": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.18.tgz",
      "integrity": "sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-darwin-arm64": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.18.tgz",
      "integrity": "sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-darwin-x64": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.18.tgz",
      "integrity": "sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-freebsd-x64": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.18.tgz",
      "integrity": "sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.18.tgz",
      "integrity": "sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-arm64-gnu": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.18.tgz",
      "integrity": "sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-arm64-musl": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.18.tgz",
      "integrity": "sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "libc": [
        "musl"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.18.tgz",
      "integrity": "sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-s390x-gnu": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.18.tgz",
      "integrity": "sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-x64-gnu": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.18.tgz",
      "integrity": "sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-linux-x64-musl": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.18.tgz",
      "integrity": "sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "libc": [
        "musl"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-openharmony-arm64": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.18.tgz",
      "integrity": "sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-win32-arm64-msvc": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.18.tgz",
      "integrity": "sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/binding-win32-x64-msvc": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.18.tgz",
      "integrity": "sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      }
    },
    "node_modules/@rolldown/pluginutils": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz",
      "integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@supabase/auth-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.119.0.tgz",
      "integrity": "sha512-WI7KqJRxqZjm77PlRO/VfKretL4SG8g8stWfBmpyx9To+fFapZlSbXA8QkduoMvr72rRpIF24ZLrjktBrp+ZFA==",
      "license": "MIT",
      "dependencies": {
        "tslib": "2.8.1"
      },
      "engines": {
        "node": ">=22.0.0"
      }
    },
    "node_modules/@supabase/functions-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.119.0.tgz",
      "integrity": "sha512-0aOdF/Y4lA+62K02nwZClfAthXmFEml0od42q3yy/9t3bBitomAOYEatTFSnxT/5UeeIDNn2DOnXTdAP0sqGNQ==",
      "license": "MIT",
      "dependencies": {
        "tslib": "2.8.1"
      },
      "engines": {
        "node": ">=22.0.0"
      }
    },
    "node_modules/@supabase/phoenix": {
      "version": "0.4.5",
      "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
      "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
      "license": "MIT"
    },
    "node_modules/@supabase/postgrest-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.119.0.tgz",
      "integrity": "sha512-qBmcjEFO7Ptyf5qvOXYJa1s4bfA4zsMIFf2i+h2zd6RRTDLwquX8z1mnS9djNOJjbiO1OGFh5Y2Lc2k2pA8TMw==",
      "license": "MIT",
      "dependencies": {
        "tslib": "2.8.1"
      },
      "engines": {
        "node": ">=22.0.0"
      }
    },
    "node_modules/@supabase/realtime-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.119.0.tgz",
      "integrity": "sha512-fPzxt+SAYweqsZUCSZma2dvN+HT6lVjwWj8BbkVwapW6wNf1odUi+ATYN7svX4Zm9vt0kz0l0VzINAUAA38FEg==",
      "license": "MIT",
      "dependencies": {
        "@supabase/phoenix": "0.4.5",
        "tslib": "2.8.1"
      },
      "engines": {
        "node": ">=22.0.0"
      }
    },
    "node_modules/@supabase/storage-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.119.0.tgz",
      "integrity": "sha512-htx1dSCnSJVPlx2BNSgLSUDvLMxuGbYa6pUPl5xoCs4as4cIhBVwuxj78zbggCyPHxLnEXjsV/qvlDsVjpodKw==",
      "license": "MIT",
      "dependencies": {
        "iceberg-js": "^0.8.1",
        "tslib": "2.8.1"
      },
      "engines": {
        "node": ">=22.0.0"
      }
    },
    "node_modules/@supabase/supabase-js": {
      "version": "2.119.0",
      "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.119.0.tgz",
      "integrity": "sha512-MKP49G6bJbPhvsZDegZ/IXS/ITApPgplDCF5+F65KZ5kIArS5Y90WFxwvWYALx2bsxdrSuHvkCiXHYr3dU8+sw==",
      "license": "MIT",
      "dependencies": {
        "@supabase/auth-js": "2.119.0",
        "@supabase/functions-js": "2.119.0",
        "@supabase/postgrest-js": "2.119.0",
        "@supabase/realtime-js": "2.119.0",
        "@supabase/storage-js": "2.119.0"
      },
      "engines": {
        "node": ">=22.0.0"
      },
      "peerDependencies": {
        "@opentelemetry/api": ">=1.0.0"
      },
      "peerDependenciesMeta": {
        "@opentelemetry/api": {
          "optional": true
        }
      }
    },
    "node_modules/detect-libc": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "picomatch": "^3 || ^4"
      },
      "peerDependenciesMeta": {
        "picomatch": {
          "optional": true
        }
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/iceberg-js": {
      "version": "0.8.1",
      "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
      "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
      "license": "MIT",
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/lightningcss": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
      "dev": true,
      "license": "MPL-2.0",
      "dependencies": {
        "detect-libc": "^2.0.3"
      },
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      },
      "optionalDependencies": {
        "lightningcss-android-arm64": "1.33.0",
        "lightningcss-darwin-arm64": "1.33.0",
        "lightningcss-darwin-x64": "1.33.0",
        "lightningcss-freebsd-x64": "1.33.0",
        "lightningcss-linux-arm-gnueabihf": "1.33.0",
        "lightningcss-linux-arm64-gnu": "1.33.0",
        "lightningcss-linux-arm64-musl": "1.33.0",
        "lightningcss-linux-x64-gnu": "1.33.0",
        "lightningcss-linux-x64-musl": "1.33.0",
        "lightningcss-win32-arm64-msvc": "1.33.0",
        "lightningcss-win32-x64-msvc": "1.33.0"
      }
    },
    "node_modules/lightningcss-android-arm64": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-darwin-arm64": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-darwin-x64": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-freebsd-x64": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm-gnueabihf": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm64-gnu": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm64-musl": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "libc": [
        "musl"
      ],
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-x64-gnu": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "libc": [
        "glibc"
      ],
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-x64-musl": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "libc": [
        "musl"
      ],
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-win32-arm64-msvc": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-win32-x64-msvc": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/nanoid": {
      "version": "3.3.20",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
      "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "bin": {
        "nanoid": "bin/nanoid.cjs"
      },
      "engines": {
        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
      }
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/picomatch": {
      "version": "4.0.7",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/postcss": {
      "version": "8.5.32",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
      "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/postcss/"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/postcss"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "nanoid": "^3.3.20",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.2"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/rolldown": {
      "version": "1.2.18",
      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.18.tgz",
      "integrity": "sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@oxc-project/types": "=0.156.0",
        "@rolldown/pluginutils": "^1.0.0"
      },
      "bin": {
        "rolldown": "bin/cli.mjs"
      },
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      },
      "optionalDependencies": {
        "@rolldown/binding-android-arm-eabi": "1.2.18",
        "@rolldown/binding-android-arm64": "1.2.18",
        "@rolldown/binding-darwin-arm64": "1.2.18",
        "@rolldown/binding-darwin-x64": "1.2.18",
        "@rolldown/binding-freebsd-x64": "1.2.18",
        "@rolldown/binding-linux-arm-gnueabihf": "1.2.18",
        "@rolldown/binding-linux-arm64-gnu": "1.2.18",
        "@rolldown/binding-linux-arm64-musl": "1.2.18",
        "@rolldown/binding-linux-ppc64-gnu": "1.2.18",
        "@rolldown/binding-linux-s390x-gnu": "1.2.18",
        "@rolldown/binding-linux-x64-gnu": "1.2.18",
        "@rolldown/binding-linux-x64-musl": "1.2.18",
        "@rolldown/binding-openharmony-arm64": "1.2.18",
        "@rolldown/binding-win32-arm64-msvc": "1.2.18",
        "@rolldown/binding-win32-x64-msvc": "1.2.18"
      }
    },
    "node_modules/source-map-js": {
      "version": "1.2.2",
      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
      "dev": true,
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.17",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.4"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
      "license": "0BSD"
    },
    "node_modules/vite": {
      "version": "8.3.7",
      "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.7.tgz",
      "integrity": "sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "lightningcss": "^1.33.0",
        "picomatch": "^4.0.7",
        "postcss": "^8.5.32",
        "rolldown": "~1.2.18",
        "tinyglobby": "^0.2.17"
      },
      "bin": {
        "vite": "bin/vite.js"
      },
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      },
      "funding": {
        "url": "https://github.com/vitejs/vite?sponsor=1"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      },
      "peerDependencies": {
        "@types/node": "^20.19.0 || >=22.12.0",
        "@vitejs/devtools": "^0.7.1 || ^0.8.0",
        "esbuild": "^0.27.0 || ^0.28.0",
        "jiti": ">=1.21.0",
        "less": "^4.0.0",
        "sass": "^1.70.0",
        "sass-embedded": "^1.70.0",
        "stylus": ">=0.54.8",
        "sugarss": "^5.0.0",
        "terser": "^5.16.0",
        "tsx": "^4.8.1",
        "yaml": "^2.4.2"
      },
      "peerDependenciesMeta": {
        "@types/node": {
          "optional": true
        },
        "@vitejs/devtools": {
          "optional": true
        },
        "esbuild": {
          "optional": true
        },
        "jiti": {
          "optional": true
        },
        "less": {
          "optional": true
        },
        "sass": {
          "optional": true
        },
        "sass-embedded": {
          "optional": true
        },
        "stylus": {
          "optional": true
        },
        "sugarss": {
          "optional": true
        },
        "terser": {
          "optional": true
        },
        "tsx": {
          "optional": true
        },
        "yaml": {
          "optional": true
        }
      }
    }
  }
}
assignment-check-day3/solution/package.json
{
  "name": "assignment-check-day3",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
    "build": "vite build",
    "preview": "vite preview --host 127.0.0.1 --port 4173"
  },
  "dependencies": {
    "@supabase/supabase-js": "2.119.0"
  },
  "devDependencies": {
    "vite": "8.3.7"
  }
}
assignment-check-day3/solution/sql/inspect.sql
-- 관리자 메타데이터 점검입니다. 아래 성공만으로 A/B 계정 RLS 검사를 대체하지 않습니다.
select relrowsecurity from pg_class where oid = 'public.tasks'::regclass;
select policyname, cmd, roles, qual, with_check from pg_policies
where schemaname = 'public' and tablename = 'tasks';
select grantee, privilege_type from information_schema.role_table_grants
where table_schema = 'public' and table_name = 'tasks' order by grantee, privilege_type;
select grantee, column_name, privilege_type from information_schema.column_privileges
where table_schema = 'public' and table_name = 'tasks' and privilege_type = 'UPDATE';
assignment-check-day3/solution/sql/setup.sql
-- 새 수업용 프로젝트에서 한 번 실행합니다. 기존 tasks가 있으면 먼저 구조를 확인하세요.
begin;
create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null default auth.uid() references auth.users(id) on delete cascade,
  title text not null check (char_length(title) between 1 and 60 and title ~ '[^[:space:]]'),
  subject text not null default '' check (char_length(subject) <= 20),
  due date not null check (due between date '0001-01-01' and date '9999-12-31'),
  done boolean not null default false,
  created_at timestamptz not null default now()
);
create index tasks_user_due_idx on public.tasks (user_id, due);
alter table public.tasks enable row level security;
revoke all on table public.tasks from public, anon, authenticated;
grant usage on schema public to authenticated;
grant select, insert, delete on table public.tasks to authenticated;
-- 소유자·id·생성시각은 UPDATE로 바꿀 수 없습니다.
grant update (title, subject, due, done) on public.tasks to authenticated;
create policy tasks_read_own on public.tasks for select to authenticated
  using ((select auth.uid()) = user_id);
create policy tasks_insert_own on public.tasks for insert to authenticated
  with check ((select auth.uid()) = user_id);
create policy tasks_update_own on public.tasks for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
create policy tasks_delete_own on public.tasks for delete to authenticated
  using ((select auth.uid()) = user_id);
commit;
assignment-check-day3/solution/src/main.js
import { supabase } from './supabase-client.js';
import { createTaskStore } from './task-store.js';
const $=s=>document.querySelector(s);
let items=[],removed=null,editingId=null,session=null,epoch=0,readVersion=0,busy=false,recovering=false;
const store=supabase?createTaskStore(supabase):null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
  return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
}
function validDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return false;
  if (hasTemporal) { try { return Temporal.PlainDate.from(value).toString() === value; } catch { return false; } }
  // 단일 파일을 오프라인에서도 쓰기 위한 로컬 구성요소 대체 검증입니다.
  const [year,month,day] = value.split('-').map(Number), date = new Date(0);
  date.setFullYear(year,month-1,day);
  return date.getFullYear()===year && date.getMonth()===month-1 && date.getDate()===day;
}
function compareDates(a,b) {
  return hasTemporal ? Temporal.PlainDate.compare(a,b) : a.localeCompare(b);
}
function shiftToday(days) {
  if (hasTemporal) return Temporal.Now.plainDateISO().add({days}).toString();
  const date = new Date(); date.setDate(date.getDate()+days); return dateValue(date);
}
function labelDate(value) { const [,month,day] = value.split('-'); return `${Number(month)}월 ${Number(day)}일`; }

const form=$('#taskForm'),interacted=new WeakSet();
function make(tag,className,text){const n=document.createElement(tag);if(className)n.className=className;if(text!==undefined)n.textContent=text;return n;}
function message(text){$('#status').textContent=text;}
function authMessage(text){$('#authMessage').textContent=text;}
function failure(error){
  const messages={email_not_confirmed:'가입 확인 메일을 먼저 열어주세요.',invalid_credentials:'이메일과 비밀번호를 확인해 주세요.',over_email_send_rate_limit:'메일 요청이 제한됐어요. 잠시 기다리거나 강사에게 발송 설정을 확인해 주세요.',weak_password:'새 비밀번호가 서버 정책에 맞지 않아요. 12자 이상으로 다시 입력해 주세요.',PGRST205:'tasks 테이블·Data API 설정을 확인해 주세요.','42501':'테이블 권한과 RLS 정책을 확인해 주세요.','23514':'입력값이 DB 규칙에 맞지 않아요.',NO_ROW:'변경할 과제를 찾지 못했어요. 목록을 다시 불러와 주세요.'};
  return messages[error?.code]||'요청을 완료하지 못했어요. 연결과 로그인 상태를 확인한 뒤 다시 시도해 주세요.';
}
function setBusy(value){busy=value;$('#inputFields').disabled=value||!session;$('#refreshBtn').disabled=value||!session;$('#sampleBtn').disabled=value||!session;$('#undoBtn').disabled=value;document.querySelectorAll('#tasks input,#tasks button').forEach(el=>el.disabled=value);}
function resetEditor(){editingId=null;form.reset();form.querySelectorAll('input').forEach(input=>{input.setCustomValidity('');input.removeAttribute('aria-invalid');interacted.delete(input);});$('#taskDue').value=dateValue();$('#newTaskTitle').textContent='새 과제 추가';$('#submitTask').textContent='+ 과제 추가';$('#cancelEdit').hidden=true;form.closest('.panel').classList.remove('editing');}
function focusTask(id){const n=[...$('#tasks').querySelectorAll('input')].find(n=>n.dataset.id===id);(n||$('#taskTitle')).focus();}
function startEdit(item){if(busy)return;resetEditor();editingId=item.id;$('#taskTitle').value=item.title;$('#taskSubject').value=item.subject;$('#taskDue').value=item.due;$('#newTaskTitle').textContent='과제 수정';$('#submitTask').textContent='변경 저장';$('#cancelEdit').hidden=false;form.closest('.panel').classList.add('editing');$('#taskTitle').focus();message('바꾼 뒤 변경 저장을 누르면 DB에 반영돼요.');}
$('#cancelEdit').addEventListener('click',()=>{const id=editingId;resetEditor();const n=[...document.querySelectorAll('[data-edit-id]')].find(n=>n.dataset.editId===id);if(n)n.focus();else focusTask(id);message('수정을 취소했어요. DB 값은 그대로예요.');});
function render(){
  const today=dateValue(),open=items.filter(i=>!i.done),done=items.length-open.length;
  $('#openCount').textContent=open.length;$('#todayCount').textContent=open.filter(i=>i.due===today).length;$('#lateCount').textContent=open.filter(i=>i.due<today).length;
  $('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
  const filter=document.querySelector('[name=filter]:checked').value,query=$('#taskSearch').value.trim().toLocaleLowerCase();$('#clearSearch').disabled=!$('#taskSearch').value;
  const shown=items.filter(i=>(filter==='all'||(filter==='done'?i.done:!i.done))&&(!query||i.title.toLocaleLowerCase().includes(query)||i.subject.toLocaleLowerCase().includes(query))).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
  $('#tasks').replaceChildren();
  for(const item of shown){
    const row=make('li','task'+(item.done?' done':'')),label=make('label','task-check'),check=make('input');check.type='checkbox';check.dataset.id=item.id;check.checked=item.done;check.setAttribute('aria-label',`${item.title} 완료 표시`);label.append(check,make('span','task-title',item.title));
    check.addEventListener('change',()=>{const next=check.checked;check.checked=item.done;mutate(async (uid,generation)=>{const saved=await store.update(uid,item.id,{done:next});if(generation!==epoch)return;items=items.map(i=>i.id===saved.id?saved:i);},'완료 상태를 DB에 저장했어요.',()=>focusTask(item.id));});
    const actions=make('div','task-actions'),edit=make('button','edit','수정'),remove=make('button','delete','삭제');edit.type=remove.type='button';edit.dataset.editId=item.id;edit.setAttribute('aria-label',`${item.title} 수정`);remove.setAttribute('aria-label',`${item.title} 삭제`);edit.addEventListener('click',()=>startEdit(item));
    remove.addEventListener('click',()=>mutate(async (uid,generation)=>{await store.remove(uid,item.id);if(generation!==epoch)return;removed=item;items=items.filter(i=>i.id!==item.id);if(editingId===item.id)resetEditor();},'DB에서 삭제했어요. 되돌릴 수 있어요.',()=>{$('#undoBtn').hidden=false;focusTask();}));
    actions.append(edit,remove);const meta=make('div','task-meta'),state=item.done?'완료':item.due<today?'기한 지남':item.due===today?'오늘 마감':'마감';meta.append(make('span','subject',item.subject||'과목 없음'),make('span','due'+(!item.done&&item.due<today?' late':!item.done&&item.due===today?' today':''),`${labelDate(item.due)} · ${state}`));row.append(label,actions,meta);$('#tasks').append(row);
  }
  $('#empty').hidden=shown.length>0;$('#emptyTitle').textContent=query?'검색 결과가 없어요.':items.length?'표시할 과제가 없어요.':'DB에 저장한 과제가 아직 없어요.';$('#emptyMessage').textContent=query?'검색어와 완료 필터를 확인해 주세요.':'새 과제를 추가하거나 가상 예제로 시작해 보세요.';$('#listSummary').textContent=`불러온 과제 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시 (수업 예제 최대 1,000개)`;setBusy(busy);
}
async function mutate(action,success,after){
  if(!session||busy)return;const generation=epoch,uid=session.user.id;readVersion++;setBusy(true);
  try{await action(uid,generation);if(generation!==epoch)return;render();message(success);after?.();}
  catch(error){if(generation===epoch)message(failure(error));}
  finally{if(generation===epoch)setBusy(false);}
}
async function refresh(generation=epoch){
  if(!session)return;const version=++readVersion,uid=session.user.id;setBusy(true);message('DB에서 과제를 불러오고 있어요.');
  try{const next=await store.list(uid);if(generation!==epoch||version!==readVersion)return;items=next;render();message('DB의 최신 목록을 불러왔어요.');}
  catch(error){if(generation===epoch&&version===readVersion)message(failure(error));}
  finally{if(generation===epoch&&version===readVersion)setBusy(false);}
}
// 브라우저 세션은 화면 복원용이며 실제 데이터 권한은 PostgreSQL RLS가 검사합니다.
function adoptSession(next,event=''){
  const changed=session?.user.id!==next?.user.id;session=next;
  if(changed||event==='SIGNED_OUT'){epoch++;readVersion++;items=[];removed=null;busy=false;resetEditor();$('#undoBtn').hidden=true;$('#taskSearch').value='';$('#filterAll').checked=true;message('');render();}
  $('#authArea').hidden=!!next;$('#appArea').hidden=!next||recovering;$('#accountArea').hidden=!next;$('#accountEmail').textContent=next?`${next.user.email} · 나의 과제`:'';
  if(next){authMessage(recovering?'복구 링크를 확인했어요. 새 비밀번호를 설정하세요.':'로그인했어요. 내 계정의 과제만 불러옵니다.');const generation=epoch;setTimeout(()=>{if(generation===epoch&&!recovering)refresh(generation);},0);}
  else authMessage('로그인하거나 회원가입하면 이 계정의 과제를 DB에 저장할 수 있어요.');
  document.querySelectorAll('#authArea fieldset').forEach(el=>el.disabled=!supabase);
}
// onAuthStateChange 안에서 다른 Auth 요청을 await하지 않습니다.
if(supabase)supabase.auth.onAuthStateChange((event,next)=>{if(event==='PASSWORD_RECOVERY'){recovering=true;$('#recoveryArea').hidden=false;}adoptSession(next,event);});
function syncField(input){if(input.validity.valid)input.removeAttribute('aria-invalid');else input.setAttribute('aria-invalid','true');}
for(const f of document.querySelectorAll('form')){
  f.addEventListener('invalid',e=>{interacted.add(e.target);syncField(e.target);},true);
  f.addEventListener('blur',e=>{if(e.target.matches('input')&&interacted.has(e.target))syncField(e.target);},true);
  f.addEventListener('input',e=>{if(e.target.matches('input')){interacted.add(e.target);e.target.setCustomValidity('');if(e.target.validity.valid)e.target.removeAttribute('aria-invalid');}});
}
form.addEventListener('submit',event=>{
  event.preventDefault();const title=$('#taskTitle'),subject=$('#taskSubject'),due=$('#taskDue');title.setCustomValidity(title.value.trim()&&title.value.trim().length<=60?'':'과제명을 1~60자로 입력하세요.');subject.setCustomValidity(subject.value.trim().length<=20?'':'과목은 20자 이내로 입력하세요.');due.setCustomValidity(validDate(due.value)?'':'올바른 날짜를 선택하세요.');[title,subject,due].forEach(syncField);if(!form.reportValidity())return;
  const id=editingId,values={title:title.value.trim(),subject:subject.value.trim(),due:due.value};
  mutate(async (uid,generation)=>{const saved=id?await store.update(uid,id,values):await store.add(uid,values);if(generation!==epoch)return;if(id)items=items.map(i=>i.id===id?saved:i);else items.push(saved);},'과제를 DB에 저장했어요.',()=>{resetEditor();$('#taskSearch').value='';$('#filterAll').checked=true;render();title.focus();});
});
$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const task=removed;mutate(async (uid,generation)=>{const saved=await store.restore(uid,task);if(generation!==epoch)return;items.push(saved);removed=null;},'삭제한 과제를 DB에 복원했어요.',()=>{$('#undoBtn').hidden=true;$('#taskSearch').value='';$('#filterAll').checked=true;render();focusTask(task.id);});});
$('#sampleBtn').addEventListener('click',()=>mutate(async (uid,generation)=>{const tasks=[['독서 감상문','국어',0],['수학 연습문제','수학',1],['영어 단어 복습','영어',-1]].map(([title,subject,offset])=>({title,subject,due:shiftToday(offset)}));const saved=await store.addMany(uid,tasks);if(generation!==epoch)return;items.push(...saved);},'가상 과제 3개를 DB에 저장했어요.',()=>{$('#taskSearch').value='';$('#filterAll').checked=true;render();}));
$('#refreshBtn').addEventListener('click',()=>refresh());$('#taskSearch').addEventListener('input',render);$('#clearSearch').addEventListener('click',()=>{$('#taskSearch').value='';render();$('#taskSearch').focus();});document.querySelectorAll('[name=filter]').forEach(el=>el.addEventListener('change',render));
for(const button of document.querySelectorAll('[data-show]'))button.addEventListener('click',()=>{const input=document.getElementById(button.dataset.show),show=input.type==='password';input.type=show?'text':'password';button.textContent=show?'숨기기':'표시';button.setAttribute('aria-pressed',String(show));});
const returnUrl=new URL('/',location.origin).href;
async function authRequest(form,action){
  if(!supabase)return;const controls=[...document.querySelectorAll('#authArea fieldset')];controls.forEach(el=>el.disabled=true);
  try{await action();}catch(error){authMessage(failure(error));}finally{controls.forEach(el=>el.disabled=false);form.querySelectorAll('input[autocomplete=current-password],input[autocomplete=new-password]').forEach(el=>el.value='');}
}
$('#signupForm').addEventListener('submit',event=>{event.preventDefault();const f=event.currentTarget;authRequest(f,async()=>{const {data,error}=await supabase.auth.signUp({email:$('#signupEmail').value.trim(),password:$('#signupPassword').value,options:{emailRedirectTo:returnUrl}});if(error)throw error;authMessage(data.session?'로그인 상태를 확인하고 있어요.':'가입 요청을 받았어요. 수신 가능한 주소라면 확인 메일을 열고 로그인해 주세요.');});});
$('#loginForm').addEventListener('submit',event=>{event.preventDefault();const f=event.currentTarget;authRequest(f,async()=>{const {error}=await supabase.auth.signInWithPassword({email:$('#loginEmail').value.trim(),password:$('#loginPassword').value});if(error)throw error;});});
$('#logoutBtn').addEventListener('click',async()=>{if(!supabase)return;$('#logoutBtn').disabled=true;try{const {error}=await supabase.auth.signOut({scope:'local'});if(error)throw error;recovering=false;$('#recoveryArea').hidden=true;adoptSession(null,'SIGNED_OUT');}catch(error){authMessage(failure(error));}finally{$('#logoutBtn').disabled=false;}});
$('#resetPassword').addEventListener('click',()=>{if(!$('#loginEmail').reportValidity())return;authRequest($('#loginForm'),async()=>{const {error}=await supabase.auth.resetPasswordForEmail($('#loginEmail').value.trim(),{redirectTo:returnUrl});if(error)throw error;authMessage('해당 계정이 있다면 재설정 메일을 보냅니다. 수신함을 확인해 주세요.');});});
$('#recoveryForm').addEventListener('submit',async event=>{event.preventDefault();const button=event.currentTarget.querySelector('button');button.disabled=true;try{const {error}=await supabase.auth.updateUser({password:$('#recoveryPassword').value});if(error)throw error;$('#recoveryPassword').value='';recovering=false;$('#recoveryArea').hidden=true;adoptSession(session);authMessage('새 비밀번호를 저장했어요. 로그아웃 후 새 비밀번호로 로그인해 확인하세요.');}catch(error){authMessage(failure(error));}finally{button.disabled=false;}});
resetEditor();render();
if(!supabase){$('#configNotice').hidden=false;authMessage('설정 전 예제입니다. 연결 설정 뒤 로그인 기능을 사용할 수 있어요.');}
else{supabase.auth.getSession().then(({data,error})=>{if(error){authMessage(failure(error));return;}adoptSession(data.session);}).catch(error=>authMessage(failure(error)));}
assignment-check-day3/solution/src/supabase-client.js
import { createClient } from '@supabase/supabase-js';

const url = import.meta.env.VITE_SUPABASE_URL || '';
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || '';
function isPublicConfig() {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && ['localhost','127.0.0.1'].includes(parsed.hostname))) return false;
    if (url.includes('YOUR_PROJECT') || key.includes('REPLACE_')) return false;
    if (key.startsWith('sb_publishable_')) return true;
    // 로컬 CLI의 legacy anon key 호환. service_role JWT는 거절합니다.
    const payload = JSON.parse(atob(key.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
    return payload.role === 'anon';
  } catch { return false; }
}
export const supabase = isPublicConfig() ? createClient(url,key,{
  auth: { flowType:'pkce', persistSession:true, autoRefreshToken:true, detectSessionInUrl:true }
}) : null;
assignment-check-day3/solution/src/task-store.js
const FIELDS = 'id,user_id,title,subject,due,done,created_at';
function item(row) {
  return {id:row.id,title:row.title,subject:row.subject,due:row.due,done:row.done,createdAt:Date.parse(row.created_at)};
}
function requireResult(result) { if(result.error)throw result.error;return result.data; }
export function createTaskStore(client) {
  return {
    async list(userId) {
      const rows=requireResult(await client.from('tasks').select(FIELDS).eq('user_id',userId).order('created_at').limit(1000));
      return rows.map(item);
    },
    async add(userId,values) {
      return item(requireResult(await client.from('tasks').insert({user_id:userId,title:values.title,subject:values.subject,due:values.due}).select(FIELDS).single()));
    },
    async addMany(userId,values) {
      const rows=requireResult(await client.from('tasks').insert(values.map(v=>({user_id:userId,title:v.title,subject:v.subject,due:v.due}))).select(FIELDS));
      return rows.map(item);
    },
    async update(userId,id,values) {
      const allowed={};
      for(const key of ['title','subject','due','done'])if(key in values)allowed[key]=values[key];
      return item(requireResult(await client.from('tasks').update(allowed).eq('id',id).eq('user_id',userId).select(FIELDS).single()));
    },
    async remove(userId,id) {
      const rows=requireResult(await client.from('tasks').delete().eq('id',id).eq('user_id',userId).select('id'));
      if(rows.length!==1)throw {code:'NO_ROW'};
    },
    async restore(userId,task) {
      return item(requireResult(await client.from('tasks').insert({id:task.id,user_id:userId,title:task.title,subject:task.subject,due:task.due,done:task.done,created_at:new Date(task.createdAt).toISOString()}).select(FIELDS).single()));
    }
  };
}
assignment-check-day3/solution/styles.css

:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:48px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
.task-actions{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.edit,.delete{font-size:12px;padding-inline:10px}.cancel{width:100%;margin-top:10px}.panel.editing{border-color:#628640;background:#f5faef}.task-actions button{min-height:48px}
@media(max-width:480px){.task-actions{flex-direction:column}.task{gap:8px}.task-meta{padding-left:0}}
.search-field{margin-bottom:16px}.search-field label{display:block;font-size:13px;font-weight:650;margin-bottom:8px}.search-row{display:flex;gap:8px}.search-row input{min-width:0;flex:1;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;background:#fff;color:var(--ink)}button:disabled{opacity:.55;cursor:default}

[hidden]{display:none!important}.auth-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:26px}.auth-grid .panel{min-width:0}.auth-note,.hint{font-size:12px;line-height:1.8;color:var(--muted);margin:0 0 9px}.auth-grid fieldset{border:0;padding:0;margin:0;min-width:0}.auth-grid legend{font-size:20px;font-weight:700;margin-bottom:14px}.auth-status,.config{padding:18px;border-radius:14px;margin-bottom:20px;background:#e6efdc;font-size:14px;line-height:1.8;overflow-wrap:anywhere}.config{background:#fff0df}.account{display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:22px;flex-wrap:wrap}.account span{overflow-wrap:anywhere}.password-row{display:flex;gap:6px}.password-row input{min-width:0;flex:1}.password-row button{padding-inline:10px;font-size:12px}.auth-grid input:user-invalid{border-color:var(--warn);background:#fff6f1}.helper-button{width:100%;margin-top:10px}.list-head{flex-wrap:wrap}.busy{color:var(--muted);font-size:13px}.auth-grid input[aria-invalid=true]{border-color:var(--warn)}
@media(max-width:760px){.auth-grid{grid-template-columns:1fr}.hero{padding-bottom:20px}}
assignment-check-day3/starter/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="과제를 기록하고 마감일과 완료 상태를 확인하는 학생용 학습 예제. 이 브라우저에만 저장됩니다.">
<title>수정과 검색 · 과제 체크 2일차</title>
<style>
:root{font-family:"Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;color:#183c32;background:#f5f7f0;--ink:#183c32;--muted:#64786e;--line:#d4ded2;--green:#204d3b;--mint:#d9f1bd;--warn:#9c4435;color-scheme:light}
*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #4b865d}button{cursor:pointer;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:white}button:hover{background:#edf3e8}a{color:var(--green);text-underline-offset:4px}.wrap{max-width:1140px;margin:auto;padding:28px 28px 45px}.top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:24px;border-bottom:1px solid var(--line)}.brand{display:flex;gap:10px;align-items:center;font-weight:800;font-size:18px}.brand svg{width:34px;height:34px}.top a{font-size:13px;padding:14px 0}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.15em;color:var(--muted);margin:0 0 15px}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:40px 0 30px}.hero h1{font-size:clamp(34px,4.4vw,54px);line-height:1.16;letter-spacing:-.055em;margin:0 0 13px}.hero p{margin:0;color:var(--muted);font-size:15px;line-height:1.8}.badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;border:1px solid #ccdbc3;padding:9px 13px;border-radius:50px;background:#edf4e5}.badge:before{content:"";width:7px;height:7px;background:#628640;border-radius:50%}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:26px}.stat{border:1px solid var(--line);border-radius:18px;padding:20px 23px;background:#fff}.stat:first-child{background:var(--green);color:#f2f9eb;border-color:var(--green)}.stat span{font-size:13px;display:block;margin-bottom:10px}.stat strong{font:600 35px ui-monospace,monospace;letter-spacing:-.05em}.stat small{font-size:12px;margin-left:7px;color:inherit}.stat.overdue strong{color:var(--warn)}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:23px;align-items:start}.panel{background:white;border:1px solid var(--line);border-radius:20px;padding:25px}.panel h2,.list-head h2{font-size:20px;letter-spacing:-.035em;margin:0 0 7px}.panel .intro{font-size:13px;line-height:1.8;color:var(--muted);margin:0 0 24px}.field{margin-bottom:19px}.field label{font-size:13px;display:block;margin-bottom:8px;font-weight:650}.field label span{font-size:11px;font-weight:400;color:var(--muted);margin-left:5px}.field input{width:100%;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;font-size:1rem;color:var(--ink);background:#fcfdf9;min-width:0}.field input:user-invalid,.field input[aria-invalid=true]{border-color:var(--warn);background:#fff6f1}.error{display:none;font-size:12px;color:var(--warn);line-height:1.6;margin:7px 0 0}.field input:user-invalid+.error,.field input[aria-invalid=true]+.error{display:block}.primary{background:var(--green);border-color:var(--green);color:#f3ffe8;width:100%;font-weight:700}.primary:hover{background:#153e2d}.privacy{font-size:12px;color:var(--muted);line-height:1.8;margin:18px 0 0}.list-area{min-width:0}.list-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 18px}.list-head p{font-size:12px;color:var(--muted);margin:0}.sample{background:#e6efdc;border-color:#c8d9bd;font-size:12px;white-space:nowrap}.filters{display:flex;gap:7px;padding:0;margin:0 0 15px;border:0;min-width:0}.filters legend{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.filters label{cursor:pointer;position:relative;display:flex;align-items:center;gap:7px;min-height:48px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;font-size:13px;background:#fff}.filters label:has(input:checked){background:#e1efd6;border-color:#88a373;font-weight:700}.filters input{accent-color:var(--green);margin:0;width:16px;height:16px}.tasks{padding:0;list-style:none;display:flex;flex-direction:column;gap:11px;margin:0}.task{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px 17px 14px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 14px;align-items:center}.task-check{display:flex;gap:13px;align-items:center;min-height:48px;cursor:pointer;min-width:0}.task-check input{width:22px;height:22px;flex:0 0 22px;accent-color:var(--green);margin:0}.task-title{font-size:16px;line-height:1.5;font-weight:650;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#7d8a80;font-weight:400}.task-meta{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-left:35px;font-size:11px;color:var(--muted);align-items:center}.subject{background:#eef2e9;padding:5px 8px;border-radius:6px}.due{padding:5px 8px;background:#f0f4ea;border-radius:6px}.due.late{color:#963d30;background:#fff0e8}.due.today{color:#5a6b1c;background:#f1f2d7}.delete{font-size:12px;border-color:transparent;color:#768078;padding-inline:10px}.empty{border:1px dashed #c3d2bd;border-radius:18px;padding:50px 25px;text-align:center;background:#f0f5e9}.empty strong{display:block;font-size:18px;margin-bottom:10px}.empty p{font-size:13px;line-height:1.8;color:var(--muted);margin:0}.list-foot{font-size:11px;color:var(--muted);line-height:1.8;margin:15px 0 0}.notice{display:flex;gap:15px;align-items:center;justify-content:space-between;min-height:50px;margin-top:18px;padding:10px 15px;border-radius:12px;background:#e1efd6;font-size:13px;line-height:1.6}.notice:has(span:empty){display:none}.notice button{min-height:48px;font-size:12px;white-space:nowrap}.storage-warning{font-size:13px;line-height:1.8;padding:15px 18px;margin:0 0 22px;background:#fff0df;border:1px solid #e4c39e;border-radius:12px}.storage-warning[hidden],button[hidden],.empty[hidden]{display:none}footer{display:flex;justify-content:space-between;gap:20px;margin-top:35px;padding-top:20px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.9}footer p{margin:0}.skip{position:absolute;top:-100px;left:20px}.skip:focus{top:10px;padding:10px;background:white;z-index:2}
@media(max-width:760px){.wrap{padding:20px 18px 32px}.top{padding-bottom:17px}.hero{padding:29px 0 23px;display:block}.hero .badge{margin-top:17px}.hero h1{font-size:36px}.stats{gap:9px;margin-bottom:20px}.stat{padding:16px 13px;border-radius:14px}.stat strong{font-size:29px}.stat span{font-size:11px}.workspace{grid-template-columns:1fr;gap:25px}.panel{padding:23px}.list-head{margin-bottom:14px}footer{display:block}footer p+p{margin-top:7px}}
@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}
.task-actions{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.edit,.delete{font-size:12px;padding-inline:10px}.cancel{width:100%;margin-top:10px}.panel.editing{border-color:#628640;background:#f5faef}.task-actions button{min-height:48px}
@media(max-width:480px){.task-actions{flex-direction:column}.task{gap:8px}.task-meta{padding-left:0}}
.search-field{margin-bottom:16px}.search-field label{display:block;font-size:13px;font-weight:650;margin-bottom:8px}.search-row{display:flex;gap:8px}.search-row input{min-width:0;flex:1;min-height:48px;border:1px solid #b8c9ba;border-radius:10px;padding:11px 12px;background:#fff;color:var(--ink)}button:disabled{opacity:.55;cursor:default}
</style>
</head>
<body>
<a class="skip" href="#taskTitle">과제 추가로 이동</a>
<div class="wrap">
<header class="top"><div class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="11" fill="#204d3b"/><path d="m10 18 5 5 11-11" fill="none" stroke="#d9f1bd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>과제 체크</div><a href="https://vibe-coding-course-three.vercel.app/assignment-day2-lab.html">2일차 실습 교재 ↗</a></header>
<main>
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 02 / SMALL CHANGES, REAL PROGRESS</p><h1 id="pageTitle">오늘 할 일을,<br>하나씩 가볍게.</h1><p>어제 만든 앱에, 오늘 필요한 변화를 더하세요.</p></div><span class="badge" id="storageBadge">이 브라우저에 저장</span></section>
<div id="storageWarning" class="storage-warning" role="alert" hidden></div>
<section class="stats" aria-label="과제 현황"><div class="stat"><span>남은 과제</span><strong id="openCount">0</strong><small>개</small></div><div class="stat"><span>오늘 마감</span><strong id="todayCount">0</strong><small>개</small></div><div class="stat overdue"><span>기한 지남</span><strong id="lateCount">0</strong><small>개</small></div></section>
<div class="workspace">
<section class="panel" aria-labelledby="newTaskTitle"><h2 id="newTaskTitle">새 과제 추가</h2><p class="intro">작게 적어두면, 시작하기 쉬워져요.</p>
<noscript><p>이 예제 앱은 JavaScript가 필요합니다. 브라우저에서 JavaScript를 켠 뒤 다시 열어주세요.</p></noscript>
<form id="taskForm" action="#newTaskTitle" method="get"><fieldset id="inputFields" disabled style="border:0;padding:0;margin:0;min-width:0"><legend style="position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden">과제 정보</legend>
<div class="field"><label for="taskTitle">과제명 <span>필수 · 60자 이내</span></label><input id="taskTitle" name="title" type="text" required maxlength="60" pattern=".*\S.*" placeholder="예: 국어 독서 감상문 쓰기" autocomplete="off" aria-describedby="titleError"><p id="titleError" class="error">⚠ 과제명을 입력해 주세요. 공백만 입력할 수 없어요.</p></div>
<div class="field"><label for="taskSubject">과목 <span>선택 · 20자 이내</span></label><input id="taskSubject" name="subject" type="text" maxlength="20" placeholder="예: 국어" autocomplete="off" aria-describedby="subjectError"><p id="subjectError" class="error">⚠ 과목은 20자 이내로 입력해 주세요.</p></div>
<div class="field"><label for="taskDue">마감일 <span>필수</span></label><input id="taskDue" name="due" type="date" required min="0001-01-01" max="9999-12-31" aria-describedby="dueError"><p id="dueError" class="error">⚠ 올바른 마감일을 선택해 주세요.</p></div>
<button class="primary" id="submitTask" type="submit">+ 과제 추가</button>
<button id="cancelEdit" class="cancel" type="button" hidden>수정 취소</button>
</fieldset></form>
<p class="privacy">이 브라우저에만 저장돼요. 다른 기기와 자동으로 공유되지 않으며, 사이트 데이터를 지우면 목록도 지워져요.</p>
</section>
<section class="list-area" aria-labelledby="taskListTitle"><div class="list-head"><div><h2 id="taskListTitle">나의 과제</h2><p id="todayLabel"></p></div><button class="sample" type="button" id="sampleBtn">예제 3개 넣기</button></div>
<div class="search-field"><label for="taskSearch">제목·과목 검색</label><div class="search-row"><input id="taskSearch" type="search" placeholder="예: 감상문 또는 국어" autocomplete="off" aria-controls="tasks"><button id="clearSearch" type="button" disabled>지우기</button></div></div>
<fieldset class="filters"><legend>과제 표시 필터</legend><label><input type="radio" name="filter" value="all" id="filterAll" checked>전체</label><label><input type="radio" name="filter" value="open" id="filterOpen">미완료</label><label><input type="radio" name="filter" value="done" id="filterDone">완료</label></fieldset>
<ul class="tasks" id="tasks" aria-label="과제 목록"></ul>
<div class="empty" id="empty"><strong id="emptyTitle">아직 등록한 과제가 없어요.</strong><p id="emptyMessage">왼쪽에서 첫 과제를 추가하거나<br>예제 3개로 먼저 둘러보세요.</p></div>
<p class="list-foot" id="listSummary" role="status"></p>
<div class="notice"><span id="status" role="status" aria-live="polite"></span><button id="undoBtn" type="button" hidden>삭제 되돌리기</button></div>
</section>
</div>
</main>
<footer><p>DAY 02 · Codex → Git · GitHub → 개선된 앱</p><p>회원가입·서버·외부 API 없이 동작합니다.</p></footer>
</div>
<script>
'use strict';
// 1. 달력 날짜는 시간대가 없는 YYYY-MM-DD 값으로 다룹니다.
const $ = selector => document.querySelector(selector);
const STORAGE_KEY = 'assignment-check-demo:v1';
let items = [], persistent = true, removed = null, editingId = null;
const hasTemporal = typeof Temporal !== 'undefined' && !!Temporal.PlainDate;
function dateValue(date = new Date()) {
  return [date.getFullYear(), String(date.getMonth()+1).padStart(2,'0'), String(date.getDate()).padStart(2,'0')].join('-');
}
function validDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return false;
  if (hasTemporal) { try { return Temporal.PlainDate.from(value).toString() === value; } catch { return false; } }
  // 단일 파일을 오프라인에서도 쓰기 위한 로컬 구성요소 대체 검증입니다.
  const [year,month,day] = value.split('-').map(Number), date = new Date(0);
  date.setFullYear(year,month-1,day);
  return date.getFullYear()===year && date.getMonth()===month-1 && date.getDate()===day;
}
function compareDates(a,b) {
  return hasTemporal ? Temporal.PlainDate.compare(a,b) : a.localeCompare(b);
}
function shiftToday(days) {
  if (hasTemporal) return Temporal.Now.plainDateISO().add({days}).toString();
  const date = new Date(); date.setDate(date.getDate()+days); return dateValue(date);
}
function labelDate(value) { const [,month,day] = value.split('-'); return `${Number(month)}월 ${Number(day)}일`; }

// 2. 저장 실패를 성공으로 표시하지 않고, 임시 모드를 안내합니다.
function temporaryMode(message) {
  persistent = false;
  $('#storageBadge').textContent = '임시 모드';
  $('#storageWarning').hidden = false;
  $('#storageWarning').textContent = message;
}
function validItem(item) {
  return item && typeof item.id==='string' && item.id.length<=100 && typeof item.title==='string' && item.title.trim().length>0 && item.title.length<=60 && typeof item.subject==='string' && item.subject.length<=20 && typeof item.due==='string' && validDate(item.due) && typeof item.done==='boolean' && Number.isFinite(item.createdAt);
}
function load() {
  try {
    const value = localStorage.getItem(STORAGE_KEY);
    if (value === null) return;
    const parsed = JSON.parse(value);
    if (!Array.isArray(parsed) || !parsed.every(validItem) || new Set(parsed.map(item=>item.id)).size!==parsed.length) throw new Error('invalid data');
    items = parsed;
  } catch {
    temporaryMode('저장된 목록을 읽을 수 없어 임시 모드로 시작했어요. 기존 저장 데이터는 덮어쓰지 않습니다. 이 화면의 새 목록은 새로고침하면 사라질 수 있어요.');
  }
}
function save() {
  if (!persistent) return;
  try { localStorage.setItem(STORAGE_KEY,JSON.stringify(items)); }
  catch { temporaryMode('브라우저 저장이 차단되었거나 저장 공간이 부족해요. 현재 목록은 화면에 유지되지만 새로고침하면 이번 변경을 잃을 수 있어요.'); }
}
function message(text) { $('#status').textContent=text; }
function make(tag,className,text) { const node=document.createElement(tag); if(className)node.className=className; if(text!==undefined)node.textContent=text; return node; }
function focusTask(id) { const target=[...$('#tasks').querySelectorAll('input')].find(input=>input.dataset.id===id); (target||$('#tasks input')||document.querySelector('[name=filter]:checked')).focus(); }

// 3. 사용자가 입력한 값은 HTML로 실행하지 않고 textContent로 표시합니다.
function render() {
  const today = hasTemporal ? Temporal.Now.plainDateISO().toString() : dateValue();
  const open=items.filter(item=>!item.done), done=items.length-open.length;
  $('#openCount').textContent=open.length;
  $('#todayCount').textContent=open.filter(item=>compareDates(item.due,today)===0).length;
  $('#lateCount').textContent=open.filter(item=>compareDates(item.due,today)<0).length;
  $('#todayLabel').textContent=`오늘 ${labelDate(today)} · 미완료 먼저, 마감일순`;
  const filter=document.querySelector('[name=filter]:checked').value;
  const query=$('#taskSearch').value.trim().toLocaleLowerCase();
  $('#clearSearch').disabled=$('#taskSearch').value.length===0;
  const shown=items.filter(item=>(filter==='all'||(filter==='done'?item.done:!item.done))&&(!query||item.title.toLocaleLowerCase().includes(query)||item.subject.toLocaleLowerCase().includes(query))).sort((a,b)=>Number(a.done)-Number(b.done)||compareDates(a.due,b.due)||a.createdAt-b.createdAt);
  const list=$('#tasks'); list.replaceChildren();
  for (const item of shown) {
    const row=make('li','task'+(item.done?' done':''));
    const label=make('label','task-check'), checkbox=make('input');
    checkbox.type='checkbox'; checkbox.checked=item.done; checkbox.dataset.id=item.id;
    checkbox.setAttribute('aria-label',`${item.title} 완료 표시`);
    label.append(checkbox,make('span','task-title',item.title));
    checkbox.addEventListener('change',()=>{item.done=checkbox.checked; save(); render(); focusTask(item.id); message(item.done?'과제를 완료했어요.':'다시 미완료로 표시했어요.');});
    const actions=make('div','task-actions'), edit=make('button','edit','수정');
    edit.type='button'; edit.dataset.editId=item.id; edit.setAttribute('aria-label',`${item.title} 수정`);
    edit.addEventListener('click',()=>startEdit(item));
    const remove=make('button','delete','삭제'); remove.type='button'; remove.setAttribute('aria-label',`${item.title} 삭제`);
    remove.addEventListener('click',()=>{
      removed={item,index:items.indexOf(item)}; items=items.filter(task=>task.id!==item.id);
      if(editingId===item.id)resetEditor();
      save(); render(); $('#undoBtn').hidden=false; message('과제를 삭제했어요. 되돌릴 수 있어요.');
      ($('#tasks input')||$('#taskTitle')).focus();
    });
    const meta=make('div','task-meta');
    meta.append(make('span','subject',item.subject||'과목 없음'));
    const state=item.done?'완료':(compareDates(item.due,today)<0?'기한 지남':compareDates(item.due,today)===0?'오늘 마감':'마감');
    const due=make('span','due'+(!item.done&&compareDates(item.due,today)<0?' late':!item.done&&compareDates(item.due,today)===0?' today':''),`${labelDate(item.due)} · ${state}`);
    meta.append(due); actions.append(edit,remove); row.append(label,actions,meta); list.append(row);
  }
  $('#empty').hidden=shown.length>0;
  $('#emptyTitle').textContent=query?'검색 결과가 없어요.':items.length===0?'아직 등록한 과제가 없어요.':filter==='done'?'완료한 과제가 아직 없어요.':'남은 과제가 없어요!';
  $('#emptyMessage').textContent=query?'검색어를 바꾸거나 완료 필터를 확인해 주세요.':items.length===0?'새 과제를 추가하거나 예제 3개로 먼저 둘러보세요.':filter==='done'?'끝낸 과제의 체크박스를 눌러보세요.':'전체 목록에서 과제를 확인하거나 새 과제를 추가해 보세요.';
  $('#listSummary').textContent=`전체 ${items.length}개 · 완료 ${done}개 · 현재 ${shown.length}개 표시`;
  $('#sampleBtn').disabled=['demo-1','demo-2','demo-3'].every(id=>items.some(item=>item.id===id));
}

// 4. 네이티브 폼 검증과 접근성 상태를 함께 사용합니다.
const form=$('#taskForm'), interacted=new WeakSet();
function syncField(input) { if (input.validity.valid)input.removeAttribute('aria-invalid'); else input.setAttribute('aria-invalid','true'); }
function resetEditor(){
  editingId=null; form.reset();
  form.querySelectorAll('input').forEach(input=>{input.setCustomValidity('');input.removeAttribute('aria-invalid');interacted.delete(input);});
  $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue();
  $('#newTaskTitle').textContent='새 과제 추가';$('#submitTask').textContent='+ 과제 추가';$('#cancelEdit').hidden=true;
  form.closest('.panel').classList.remove('editing');
}
function startEdit(item){
  resetEditor(); editingId=item.id;
  $('#taskTitle').value=item.title;$('#taskSubject').value=item.subject;$('#taskDue').value=item.due;
  $('#newTaskTitle').textContent='과제 수정';$('#submitTask').textContent='변경 저장';$('#cancelEdit').hidden=false;
  form.closest('.panel').classList.add('editing');$('#taskTitle').focus();message('제목·과목·마감일을 바꾼 뒤 변경 저장을 눌러주세요.');
}
$('#cancelEdit').addEventListener('click',()=>{
  const id=editingId;resetEditor();
  const trigger=[...document.querySelectorAll('[data-edit-id]')].find(button=>button.dataset.editId===id);
  if(trigger)trigger.focus();else focusTask(id);
  message('수정을 취소했어요. 원래 과제는 그대로예요.');
});
form.addEventListener('invalid',event=>{interacted.add(event.target);syncField(event.target);},true);
form.addEventListener('blur',event=>{if(event.target.matches('input')&&interacted.has(event.target))syncField(event.target);},true);
form.addEventListener('input',event=>{if(event.target.matches('input')){interacted.add(event.target);event.target.setCustomValidity(''); if(event.target.validity.valid)event.target.removeAttribute('aria-invalid');}});
form.addEventListener('submit',event=>{
  event.preventDefault();
  const title=$('#taskTitle'),subject=$('#taskSubject'),due=$('#taskDue');
  title.setCustomValidity(title.value.trim().length>0&&title.value.trim().length<=60?'':'과제명을 1~60자로 입력해 주세요.');
  subject.setCustomValidity(subject.value.trim().length<=20?'':'과목은 20자 이내로 입력해 주세요.');
  due.setCustomValidity(validDate(due.value)?'':'올바른 마감일을 선택해 주세요.');
  [title,subject,due].forEach(syncField);
  if (!form.reportValidity()) return;
  const wasEditing=editingId!==null;
  if(wasEditing){
    const item=items.find(task=>task.id===editingId);
    if(!item){resetEditor();message('수정할 과제를 찾을 수 없어요. 다시 선택해 주세요.');return;}
    // 동일 항목의 편집 가능한 값만 변경: id, done, createdAt은 보존합니다.
    Object.assign(item,{title:title.value.trim(),subject:subject.value.trim(),due:due.value});
  }else{
    items.push({id:crypto.randomUUID?crypto.randomUUID():`task-${Date.now()}-${Math.random().toString(36).slice(2)}`,title:title.value.trim(),subject:subject.value.trim(),due:due.value,done:false,createdAt:Date.now()});
  }
  save(); $('#filterAll').checked=true; $('#taskSearch').value=''; resetEditor(); render();
  title.focus(); message(persistent?(wasEditing?'과제 변경을 저장했어요.':'과제를 추가하고 이 브라우저에 저장했어요.'):'변경했어요. 현재 임시 모드입니다.');
});
$('#taskSearch').addEventListener('input',render);
$('#clearSearch').addEventListener('click',()=>{$('#taskSearch').value='';render();$('#taskSearch').focus();});
document.querySelectorAll('[name=filter]').forEach(input=>input.addEventListener('change',render));
$('#undoBtn').addEventListener('click',()=>{if(!removed)return;const restored=removed.item;items.splice(removed.index,0,restored);removed=null;save();$('#filterAll').checked=true;$('#taskSearch').value='';render();$('#undoBtn').hidden=true;focusTask(restored.id);message('삭제한 과제를 되돌렸어요.');});
$('#sampleBtn').addEventListener('click',()=>{
  const samples=[['demo-1','국어 독서 감상문','국어',0],['demo-2','수학 연습문제 5개','수학',1],['demo-3','영어 단어 복습','영어',-1]];
  samples.forEach(([id,title,subject,offset],index)=>{if(!items.some(item=>item.id===id))items.push({id,title,subject,due:shiftToday(offset),done:false,createdAt:Date.now()+index});});
  save();$('#filterAll').checked=true;$('#taskSearch').value='';render();message('가상 과제 3개를 넣었어요. 오늘 마감과 기한 지남을 비교해 보세요.');
});
load(); $('#taskDue').value=hasTemporal?Temporal.Now.plainDateISO().toString():dateValue(); render();
$('#inputFields').disabled=false;
window.addEventListener('focus',render);
</script>
</body>
</html>
assignment-check-day3/student-teacher-steps.txt
C01 같은 앱의 저장 위치와 완료 기준 정하기
학생: 전날 수정·검색 앱을 열고 “이 브라우저 저장”과 “계정별 DB 저장”의 차이를 적는다. 오늘의 완료 기준은 회원가입→이메일 확인→로그인→내 과제 저장→새로고침·다른 브라우저 재조회→다른 계정 차단→공개 Vercel URL이다.

확인: 필수 범위는 이메일 로그인과 회원별 tasks CRUD다. Google·Kakao·Naver·MFA는 4일차에서 이어간다. 브라우저의 전날 목록은 자동 업로드하지 않으며 가상 과제를 새로 만든다.
강사: 1교시 도입8분 중 AI Studio는 선택 비교 시연5분만 사용한다. 주 실습은 Codex 경로다. 프런트엔드 HTML/CSS/JavaScript, Vite 빌드 도구, Supabase PostgreSQL·Auth·Data API의 역할을 실제 앱으로 비교한다. 별도 서버 언어를 새로 배우는 시간이 아니다.

C02 기능 브랜치와 Vite·공개 설정 준비
학생: 본인 저장소의 깨끗한 main을 최신화하고 feature/supabase-tasks를 만든다. Codex에 요청1을 입력해 기존 화면을 Vite 프로젝트로 구성한다. ZIP solution을 참고하면 index.html/styles.css/src/·package.json·lockfile·sql/을 저장소 루트에 둔다. .env.example을 .env.local로 복사해 본인 수업 프로젝트 URL과 publishable key를 입력하고 npm ci, npm run dev를 실행한다.
git status -sb
git switch main
git pull --ff-only origin main
git switch -c feature/supabase-tasks
node --version
npm ci
npm run dev
확인: Node는 설치 버전의 요구사항을 만족해야 한다(제공 예제는22.12이상). .env.local은 Git에서 무시된다. VITE_ 값은 브라우저에 포함되는 공개 설정이며 secret/service_role을 숨겨주는 기능이 아니다.
강사: 2일차에 clone한 폴더를 그대로 사용한다. Vite는 기존 HTML·JS를 빌드하는 도구다. ZIP의 solution 안 파일을 저장소 루트에 놓고 .git은 교체하지 않는다. GitHub Pages는 Vite 소스를 그대로 실행하지 못하므로 3일차 공개 주소는 Vercel로 기록한다. 이전 Pages 화면은 2일차 커밋에서 다시 확인할 수 있다.

C03 새 수업용 DB에 tasks·GRANT·RLS 적용
학생: 자신의 새 수업용 Supabase 프로젝트에서 SQL Editor를 열어 sql/setup.sql 전체를 한 번 실행한다. 기존 tasks가 있으면 구조와 정책부터 비교하고 임의로 삭제하지 않는다. sql/inspect.sql로 RLS·네 정책·authenticated의 접근 권한과 편집 가능한 열을 확인한다. Data API의 public 노출과 테이블 권한을 함께 확인한다.

확인: tasks.user_id는 auth.users.id와 연결된다. SELECT/DELETE는 소유자 USING, INSERT는 WITH CHECK, UPDATE는 USING+WITH CHECK다. anon에는 권한이 없다. 명시적 GRANT와 RLS는 각각 “테이블 접근”과 “행 접근”을 담당한다.
강사: 1교시50분은 도입8·프로젝트/환경10·스키마/RLS20·짝점검8·정리4다. 테이블이 보인다는 이유로 API 접근 성공을 단정하지 않는다. 최신 Supabase의 자동 노출 변화에 맞춰 명시적 GRANT를 사용한다. SQL Editor의 관리자 권한은 일반 사용자 RLS 검사를 대신하지 못한다. 프로젝트 생성·계정 준비는 수업 전에 마친다.

C04 회원가입·메일 확인·로그인·로그아웃 연결
학생: 요청2를 Codex에 입력하고 Authentication의 Email 가입과 Confirm email을 확인한다. 비밀번호 최소12자 정책을 앱과 서버에서 맞춘다. URL Configuration의 Site URL과 Redirect URLs에 실제 로컬 루트(http://localhost:5173/ 등)를 설정한다. 수신 가능한 수업 계정 A로 가입하고 메일 확인 뒤 로그인·새로고침·로그아웃을 각각 시험한다.

확인: 가입 요청 성공과 메일 도착·확인·로그인 성공을 따로 기록한다. PKCE 확인 링크는 요청한 같은 브라우저에서 연다. 다른 브라우저에서 확인했다면 이메일 확인 완료 후 원래 앱에서 이메일/비밀번호 로그인을 다시 시도한다.
강사: 2교시 50분은 흐름8·Auth 구현22·메일/세션8·짝 검증8·정리4다. 기본 SMTP는 발송 대상과 사용량 제한이 있다. 강사는 Resend 검증 도메인·SMTP 또는 로컬 Mailpit를 사전 준비한다. 메일이 막히면 이메일 확인 기능을 끄지 않고 발송 경로를 고친다. 비밀번호 분실 복구·변경은 완성 예제와 F09~F14를 보충으로 사용한다.

C05 내 과제 CRUD를 DB에 저장하고 다시 읽기
학생: A로 로그인해 가상 과제 두 개를 DB에 추가한다. 완료 상태를 바꾸고 제목·과목·마감일을 수정한다. 새로고침과 새로 불러오기로 DB 값을 재조회하고 삭제/되돌리기도 확인한다. 제목·과목 검색과 완료 필터·오늘/기한 지남 집계가 현재 계정 목록에서 작동하는지 확인한다.

확인: insert/update/delete의 error와 반환 행을 확인해야 성공이다. 수정 후 id·done·created_at과 개수는 유지된다. 다른 기기에서 같은 계정으로 로그인한 뒤 다시 조회하면 같은 DB 목록을 읽는다. 이 예제의 새로 불러오기는 Realtime 자동 동기화가 아니다.
강사: 3교시 50분은 읽기/쓰기8·CRUD 구현18·계정/권한12·짝 검증8·정리4다. DB 저장에서는 비동기 오류와 인증 상태를 함께 다룬다. 저장 실패 때 입력값을 남기고 성공 안내를 보내지 않는지 보여준다. 예제는 최대1,000건을 조회하며 대량 데이터는 페이지 나누기를 추후 설계한다.

C06 A·B 계정과 미로그인으로 권한 검증
학생: 브라우저 일반 창의 A와 별도 프로필/다른 브라우저의 B로 각각 로그인한다. A의 과제 id를 가상 테스트 기록에만 적고, B의 Data API로 A id 조회·수정·삭제와 user_id 위조 INSERT를 검사한다. 로그아웃 직후 목록과 폼이 비워지는지도 확인한다. 상세 검증표의 실제 결과를 기록한다.

확인: A는 자기 행만 CRUD, B의 A 조회는0건, 수정·삭제는0건 또는 오류, 위조 INSERT·소유자 변경은거절되어야 한다. 미로그인도 데이터에 접근하지 못한다. 화면이 숨겨졌다는 이유만으로 RLS 통과라 하지 않는다.
강사: 완성 ZIP의 절차와 Codex 검증 요청을 사용한다. 토큰을 스크린샷이나 과제물에 넣지 않는다. 화면 검색으로 A의 과제를 숨기는 것과 DB가 접근을 거절하는 것은 다르다. 빈 목록이 테이블 권한 오류인지 정상 RLS 차단인지 구분한다. 계정을 바꾸는 동안 도착한 이전 요청의 응답도 다른 계정 화면에 나타나지 않아야 한다.

C07 Git PR → Vercel Vite 배포
학생: 아래처럼 소스·SQL·lockfile·환경 예시만 stage하고 diff를 검토해 commit·push한다. 본인 저장소의 main을 대상으로 PR을 만들고 검토·병합한다. Vercel Add New Project→본인 GitHub 저장소 Import에서 앱이 있는 Root Directory, Vite, npm run build, dist를 확인한다. 두 VITE 공개 변수를 Production과 필요한 Preview에 입력하고 Deploy한다.
npm run build
git status -sb
git add index.html styles.css src package.json package-lock.json .gitignore .env.example sql
git diff --cached
git commit -m "Add Supabase auth and per-user tasks"
git push -u origin feature/supabase-tasks
확인: solution 안 파일을 저장소 루트에 넣었다면 Root Directory는 루트다. solution 폴더 자체를 저장소에 넣었다면 실제 경로를 지정한다. Vercel이 Next.js 또는 public/을 빌드하도록 설정되어 있으면 고친다. DB 비밀번호나 service_role은 입력하지 않는다.
강사: 4교시 50분은 빌드/Git8·Vercel12·URL/메일10·공개 A/B 검증12·짝 확인/정리8이다. 전날의 PR 절차를 재사용한다. Framework 자동 감지 결과를 검토한다. 환경값은 빌드에 포함되므로 변경 후 재배포한다. 앱 배포와 DB 스키마 적용은 별개이며 Vercel이 SQL을 자동 실행하지 않는다.

C08 공개 URL·인증 복귀·DB 재조회 최종 확인
학생: Vercel의 실제 Production URL을 Supabase Site URL로 넣고 Redirect URLs에 정확한 공개 루트와 필요한 로컬/Preview 루트를 허용한다. 설정 변경 뒤 새 배포를 만들고 가입메일이 공개 앱으로 돌아오는지 확인한다. 공개 앱에서 추가·수정·검색·완료·로그아웃·새로고침·다른 브라우저 A 재조회·B 차단을 다시 검사한다.
git switch main
git pull --ff-only origin main
git log --oneline -3
확인: 제출: PR URL·main 커밋·배포 커밋·Vercel URL·Supabase 프로젝트 식별자(키 제외)·메일/로그인/DB/권한 체크표. READY는 빌드·배포 상태이며 실제 가입·저장 통과와 다르다. 대기나 미실행은 미확인으로 기록한다.
강사: Resend SMTP는 Supabase Auth 설정에 보관한다. 앱이 별도 메일을 보내지 않는다면 Resend 키를 Vercel 클라이언트 환경에 넣지 않는다. SNS와 MFA는4일차에 진행하고 오늘은 이메일·DB·배포를 완주한다. 강의자료의 배포 검증과 학생 개인 프로젝트의 실제 통합 검증을 구분한다.