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

DAY 04 / SOURCE FILES

예제 코드 · 읽고 비교하기

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

종합판: 폴더 구조를 유지하고5일 전체 자료 링크로 다른 일차를 선택하세요.

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

파일 선택

assignment-check-day4/README.md
4일차 과제 앱 연속 실습

starter=3일차 DB앱, checkpoint-sns=SNS+네이버어댑터(MFA전), solution=TOTP와AAL2강제까지완성.
본인clone저장소루트에서단계별파일을비교하고.git/.env.local은보존합니다. 실제키·계정·QR·TOTP비밀은포함하지않았습니다. npm ci→npm run dev. 서버어댑터는Vite정적서버에서실행되지않으므로Vercel dev또는본인고정수업배포에서검사합니다.

MFA SQL은가상계정등록성공을먼저확인한뒤수업DB에만적용합니다. 공개Supabase·개발자앱설정은본인것을입력하세요.
상세교재: https://vibe-coding-course-three.vercel.app/assignment-day4-lab.html
검증범위: 로컬실제TOTP/AAL/RLS, SNS SDK인자와네이버어댑터모의검사. 실SNS승인/동의/callback은수업계정에서확인합니다.
assignment-check-day4/checkpoint-sns/.env.example
VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REPLACE_WITH_YOUR_KEY
assignment-check-day4/checkpoint-sns/.gitignore
node_modules/
dist/
.env
.env.*
!.env.example
.vercel/
assignment-check-day4/checkpoint-sns/README.md
# 4일차 SNS 중간 코드

3일차 앱에 Google·Kakao·custom:naver와 서버userinfo어댑터를 추가한 지점입니다. 아직 MFA UI는 없습니다. 제공자 설정과 이메일 없는 계정·취소·재로그인을 검사합니다. sql/mfa-required.sql은완성본의TOTP등록성공을확인한뒤에만적용하세요. 이중간코드에MFA정책부터적용하면과제가차단됩니다.

서버함수는Vite정적서버에서실행되지않습니다. Vercel dev또는고정수업배포의/api/naver-userinfo를사용합니다. 공개URL/publishable key만.env.local에설정하고ClientSecret는Supabase에보관합니다.
assignment-check-day4/checkpoint-sns/api/naver-userinfo.js
import {fetchNaverClaims,ProfileError} from '../server/naver-profile.js';
export default async function handler(req,res) {
  res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff');
  if(req.method!=='GET'){res.setHeader('Allow','GET');return res.status(405).json({error:'method_not_allowed'});}
  try{return res.status(200).json(await fetchNaverClaims(req.headers.authorization));}
  catch(error){return res.status(error instanceof ProfileError?error.status:502).json({error:'profile_verification_failed'});}
}
assignment-check-day4/checkpoint-sns/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>나의 과제, 어디서든 · 과제 체크 4일차</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<a class="skip" href="#main">본문으로 이동</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-day4-lab.html">4일차 실습 교재 ↗</a></header>
<main id="main" tabindex="-1">
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 04 / SIGN IN, STAY SECURE</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="계정 로그인과 가입">
<section id="socialArea" class="panel social-panel" aria-labelledby="socialHeading"><h2 id="socialHeading">SNS 계정으로 시작하기</h2><p class="auth-note">수업용 제공자 설정을 마친 뒤 사용하세요. 과제는 로그인한 계정별로 관리됩니다.</p><div class="social-buttons"><button type="button" data-provider="google" disabled>Google로 로그인</button><button type="button" data-provider="kakao" disabled>Kakao로 로그인</button><button type="button" data-provider="custom:naver" disabled>Naver로 로그인</button></div></section>
<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 04 · SNS 로그인 + TOTP MFA</p><p>본인 계정의 과제만 표시됩니다. 새로 불러오기로 최신 목록을 확인하세요.</p></footer>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
assignment-check-day4/checkpoint-sns/package-lock.json
{
  "name": "assignment-check-day4",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "assignment-check-day4",
      "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-day4/checkpoint-sns/package.json
{
  "name": "assignment-check-day4",
  "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-day4/checkpoint-sns/server/naver-profile.js
export class ProfileError extends Error { constructor(status){super('profile verification failed');this.status=status;} }
export function toNaverClaims(body) {
  const id=body?.response?.id;
  if(body?.resultcode!=='00'||typeof id!=='string'||!id.trim()||id.length>255)throw new ProfileError(502);
  // Naver의 검증된 앱별 고유 id만 사용합니다. 확인되지 않은 이메일을 연결 근거로 삼지 않습니다.
  return {sub:id};
}
export async function fetchNaverClaims(authorization,fetcher=fetch) {
  if(typeof authorization!=='string'||authorization.length>8192||!/^Bearer [A-Za-z0-9._~+\/-]+=*$/i.test(authorization))throw new ProfileError(401);
  let response;
  try {response=await fetcher('https://openapi.naver.com/v1/nid/me',{
    method:'GET',headers:{Authorization:authorization,Accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(5000)
  });}catch{throw new ProfileError(502);}
  if(!response.ok)throw new ProfileError([401,403].includes(response.status)?401:502);
  const reader=response.body?.getReader();if(!reader)throw new ProfileError(502);
  const decoder=new TextDecoder();let length=0,text='';
  try{while(true){const {done,value}=await reader.read();if(done)break;length+=value.byteLength;if(length>65536){await reader.cancel();throw new ProfileError(502);}text+=decoder.decode(value,{stream:true});}text+=decoder.decode();return toNaverClaims(JSON.parse(text));}
  catch{throw new ProfileError(502);}
}
assignment-check-day4/checkpoint-sns/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-day4/checkpoint-sns/sql/mfa-required.sql
-- 3일차 tasks 소유자 정책·GRANT를 유지합니다. 새 수업용 DB에서만 실행하세요.
-- 먼저 가상 계정의 TOTP 등록·AAL2를 확인하세요. 적용 후 모든 사용자에게 MFA가 필요합니다.
begin;
create policy tasks_require_aal2 on public.tasks
  as restrictive for all to authenticated
  using (((select auth.jwt())->>'aal') = 'aal2')
  with check (((select auth.jwt())->>'aal') = 'aal2');
commit;
-- 정책 확인 (관리자 메타데이터 점검이며 실제 AAL1/AAL2 API 검사를 대신하지 않습니다.)
select policyname, permissive, cmd, qual, with_check
from pg_policies where schemaname='public' and tablename='tasks';
assignment-check-day4/checkpoint-sns/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-day4/checkpoint-sns/src/main.js
import {startSocialLogin,readOAuthFailure} from './social-auth.js';
import { supabase } from './supabase-client.js';
import { createTaskStore } from './task-store.js';
const $=s=>document.querySelector(s);
const oauthFailed=readOAuthFailure(location);
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||'SNS 계정'} · 나의 과제`:'';
  if(next){authMessage(recovering?'복구 링크를 확인했어요. 새 비밀번호를 설정하세요.':'로그인했어요. 내 계정의 과제만 불러옵니다.');const generation=epoch;setTimeout(()=>{if(generation===epoch&&!recovering)refresh(generation);},0);}
  else authMessage(oauthFailed?'SNS 로그인이 취소되었거나 완료되지 않았어요. 다시 시도해 주세요.':'로그인하거나 회원가입하면 이 계정의 과제를 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)));}

const socialButtons=[...document.querySelectorAll('[data-provider]')];
for(const button of socialButtons){
  button.disabled=!supabase;
  button.addEventListener('click',async()=>{
    if(!supabase)return;socialButtons.forEach(b=>b.disabled=true);authMessage('선택한 제공자로 이동하고 있어요.');
    try{await startSocialLogin(supabase,button.dataset.provider,location.origin);}
    catch{authMessage('SNS 로그인을 시작하지 못했어요. 제공자 설정과 허용 주소를 확인해 주세요.');socialButtons.forEach(b=>b.disabled=false);}
  });
}
if(readOAuthFailure(location)){
  authMessage('SNS 로그인이 취소되었거나 완료되지 않았어요. 다시 시도해 주세요.');
  history.replaceState(null,'',location.pathname);
}
assignment-check-day4/checkpoint-sns/src/social-auth.js
export const SOCIAL_PROVIDERS = Object.freeze(['google','kakao','custom:naver']);
export async function startSocialLogin(client,provider,origin) {
  if(!SOCIAL_PROVIDERS.includes(provider))throw new Error('unsupported provider');
  const redirectTo=new URL('/',origin).href;
  const {error}=await client.auth.signInWithOAuth({provider,options:{redirectTo}});
  if(error)throw error;
}
export function readOAuthFailure(location) {
  const query=new URLSearchParams(location.search),hash=new URLSearchParams(location.hash.replace(/^#/,''));
  return query.has('error')||query.has('error_code')||hash.has('error')||hash.has('error_code');
}
assignment-check-day4/checkpoint-sns/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-day4/checkpoint-sns/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-day4/checkpoint-sns/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}}

.social-panel{margin-bottom:22px}.social-buttons{display:flex;gap:10px;flex-wrap:wrap}.social-buttons button{flex:1;min-width:150px;font-weight:650}.social-buttons [data-provider=kakao]{background:#f9e877;color:#2d2610}.social-buttons [data-provider="custom:naver"]{background:#e2f5e2;color:#174d2a}
assignment-check-day4/checks.csv
검사,행동,기대결과,통과/실패/미확인,실제결과·확인자
공통주소,제공자 callback / 앱 복귀 루트,각 설정 위치 일치,,
Google,최초·취소·재로그인,세션·identity·uid 확인,,
Kakao,키·Secret·동의·메일 없음,정상 로그인 또는 필요한 가입 안내,,
Naver,custom:naver·고정 UserInfo URL,서버 검증 후 동일 sub,,
어댑터실패,무토큰·잘못된 토큰·오류 응답,401 또는 정형 오류·토큰 출력 없음,,
기존회원,동일 메일·별개 identity 비교,확인된 서버 연결 결과·역할 복사 없음,,
MFA등록,enroll만 실행,"AAL1, 과제 접근 허용 안 됨",,
MFA오답,틀린/지난 코드,거절·재입력 안내,,
MFA성공,challengeAndVerify,AAL2 확인 뒤 과제 표시,,
재로그인,로그아웃→이메일/SNS 로그인,AAL1→추가 인증 필요,,
DB본인AAL1,조회·추가·수정·삭제 요청,조회0건·쓰기거절/0건,,
DB본인AAL2,본인 과제 CRUD,성공·새로고침 유지,,
DB타인AAL2,B가 A id 요청,조회/수정/삭제0건·위조거절,,
비밀정리,완료·취소·계정전환,QR·수동 키·코드 DOM/메모리 제거,,
계정경합,A 요청 중 B로 변경,늦은 A 응답 표시 없음,,
공개배포,Vercel URL·배포commit·서버함수,공개 UI와 실제 제공자 왕복 구분,,
assignment-check-day4/prompts.txt
socialPrompt
3일차 Vite·Supabase 과제 앱과 현재 Git 상태를 먼저 읽어줘. 기존 이메일 로그인·DB CRUD·수정·검색·소유자 RLS를 보존하고 Google·Kakao SNS 로그인 버튼을 추가해줘. signInWithOAuth의 provider는 google/kakao, redirectTo는 현재 앱의 고정 루트로 연결하고 기존 PKCE 클라이언트를 사용해줘. 제공자 개발자센터의 callback은 Supabase가 표시한 Callback URL, Supabase Redirect URLs는 앱 복귀 주소라는 차이를 설정표로 만들어줘. Client Secret·Admin 키는 앱이나 VITE_에 넣지 말고 Supabase 제공자 설정에만 보관해줘. 취소·동의 누락·메일 없는 SNS 계정·중복 클릭·허용되지 않은 복귀 주소를 처리해줘. 에러 설명·코드·토큰을 URL에서 그대로 화면/로그에 출력하지 마. 사용자 소유권은 Supabase user.id로 유지하고 이메일이 같다는 이유로 과제나 역할을 클라이언트에서 옮기지 마. 최초 가입·취소·재로그인과 기존 사용자 identity 연결 조건을 확인할 체크표를 줘. 실제 제공자 계정으로 실행하지 않은 항목은 미확인으로 써줘.

naverPrompt
과제 앱에 네이버 로그인을 custom:naver로 추가해줘. 네이버는 OIDC 자동검색이 아닌 Custom OAuth2로 구성하고 Authorization/Token/UserInfo 설정, 네이버 개발자 앱과 Supabase가 표시한 Callback URL의 관계를 설명해줘. 서버의 GET /api/naver-userinfo는 Supabase가 보낸 Bearer 토큰으로 고정 https://openapi.naver.com/v1/nid/me만 조회해줘. redirect 거절·timeout·응답 크기 제한·resultcode와 response.id 검증을 넣고 검증한 앱별 id만 sub로 반환해줘. 요청 body나 임의 JSON을 신뢰하거나 이메일을 합성·email_verified=true로 만들지 마. 수업 앱은 연락 이메일이 필요 없으므로 이 제공자만 email_optional=true로 구성하고 이메일 없는 SNS 계정도 표시해줘. Naver Client Secret은 Supabase 설정에 보관하고 어댑터는 토큰을 저장/로그/응답하지 않게 해줘. 이 서버 함수는 Vite 정적 미리보기에서 실행되지 않는다고 설명하고, 접근 가능한 고정 Vercel 수업 URL에 배포한 뒤 UserInfo URL에 등록하게 해줘. 실패·이메일 누락·취소·재로그인·같은 identity 검증을 포함하고 실계정 미실행과 모의 검사 결과를 구분해줘.

mfaPrompt
현재 앱에 Supabase TOTP MFA를 추가해줘. final 앱은 이메일/SNS 로그인 후 모두 AAL2를 확인해야 tasks를 열게 해줘. getAuthenticatorAssuranceLevel과 listFactors로 미등록·등록대기·기등록 AAL1·AAL2를 구분하고 enroll→QR/수동 키→challengeAndVerify→AAL2 확인을 연결해줘. 등록은 사용자 버튼으로 한 번만 시작하고 TOTP는 text/inputmode numeric/one-time-code/6자리 문자열로 받아 앞자리0을 보존해줘. SDK QR은 img로 표시하고 원본SVG를 innerHTML로 삽입하지 마. QR·비밀키·코드는 로그·URL·영구 저장·과제물에 남기지 않고 완료/취소/계정전환 시 메모리와 DOM에서 지워줘. 같은 세션의 중복 이벤트는 폼을 지우지 않게 하고 새 세션/계정 전환은 이전 요청을 무시해줘. 기존 user_id RLS/GRANT는 보존하고 tasks에 AS RESTRICTIVE FOR ALL TO authenticated, USING과 WITH CHECK로 aal2를 요구하는 SQL을 별도 파일로 만들어줘. 가상 계정의 TOTP 성공을 먼저 확인한 뒤 새 수업 DB에 적용하게 해줘. 틀린/지난 코드·재로그인 challenge·AAL1 차단/AAL2 본인 허용·타인 차단·미완료 등록 취소·분실 시 정식 복구 안내를 포함해줘. 운영 DB/기존 계정은 변경하지 마.

verifyPrompt
수업용 앱의 Google·Kakao·Naver 각각을 최초 가입/취소/재로그인/필요 동의 누락으로 검사해줘. Supabase user.id와 identities를 비교하고 같은 이메일만으로 기존 과제/역할을 옮기지 않는지 확인해줘. 네이버 어댑터는 고정 upstream·Bearer 검증·응답 오류·sub 매핑·이메일 합성 없음·no-store를 검사해줘. MFA는 등록 전→등록만 완료→잘못된 코드→검증 성공→로그아웃/재로그인→추가 인증을 확인해줘. DB Data API에서 본인 AAL1 조회0/쓰기거절, 본인 AAL2 CRUD 성공, B의 A 과제 접근 차단을 실제 세션으로 검사해줘. SQL Editor 관리자 결과나 버튼 숨김만으로 통과라 하지 마. QR·비밀키·인증코드·토큰은 보고서/스크린샷에 넣지 마. 실제 SNS 계정·배포·설정과 모의/로컬 검증을 따로 기록하고 공개 Vercel URL에서의 동작은 미실행이면 미확인으로 남겨줘.
assignment-check-day4/provider-settings.csv
제공자,SDK provider,개발자센터,키보관,callback,설정/실계정검증상태
Google,google,Google Cloud · Google Auth Platform,웹 OAuth Client ID + Secret → Supabase Google,Supabase Google 화면의 Callback URL,
Kakao,kakao,Kakao Developers · Platform Key / Kakao Login,REST API 키 + 활성화된 Login Client Secret → Supabase Kakao,사용할 REST 키의 Redirect URI에 Supabase callback,
Naver,custom:naver,Naver Developers + Supabase Custom OAuth2,Client ID + Secret → Supabase Custom Provider,Custom Provider 생성 화면의 Callback URL 원문,
assignment-check-day4/solution/.env.example
VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REPLACE_WITH_YOUR_KEY
assignment-check-day4/solution/.gitignore
node_modules/
dist/
.env
.env.*
!.env.example
.vercel/
assignment-check-day4/solution/README.md
# 과제 체크 앱 · 4일차 SNS + TOTP MFA

3일차 Vite/Supabase 앱에 Google·Kakao·Naver 로그인, 인증앱 추가 인증과 DB AAL2 정책을 연결합니다.

1. 기존 회원별 tasks DB와 이메일 로그인, .env.local의 공개 URL/publishable key를 유지합니다. 처음 시작하는 새 수업 DB라면 sql/setup.sql을 한 번 적용합니다.
2. Google/Kakao 제공자 설정에 개발자 앱의 Client ID/Secret을 등록합니다. 제공자 callback은 Supabase 화면에서 복사하고 앱 복귀 루트는 Supabase Redirect URLs에 등록합니다. Secret·Admin키를 VITE_에 넣지 않습니다.
3. Naver는 custom:naver Custom OAuth2를 사용합니다. api/naver-userinfo.js와 server/naver-profile.js를 Vercel에 함께 배포하고 고정 HTTPS UserInfo URL을 Supabase에 등록합니다. Vite 정적 서버는 이 함수를 실행하지 않습니다. 실제 통합은 Vercel dev 또는 고정된 수업 배포에서 확인하세요.
4. Naver Authorization=https://nid.naver.com/oauth2.0/authorize, Token=https://nid.naver.com/oauth2.0/token. 이 제공자에 email_optional=true를 설정합니다. 어댑터는 서버에서 검증한 response.id만 sub로 반환하고 이메일은 합성/자동 확인하지 않습니다. 개인 연락 이메일이 필요하면 별도 확인 흐름을 설계하세요.
5. Supabase Auth의 TOTP 등록·검증을 활성화합니다. final 앱은 로그인 후 MFA가 필수입니다. 인증앱 등록→코드 검증→AAL2를 확인한 뒤 tasks를 엽니다. QR·수동 키·코드는 저장/공유/로그하지 않습니다.
6. 가상 계정의 등록 성공을 먼저 확인한 뒤 sql/mfa-required.sql을 수업 DB에 적용합니다. 기존 소유자 정책을 유지하며 AS RESTRICTIVE로 모든 tasks 접근에 AAL2를 추가 요구합니다. 학생 A/B 모두 MFA가 필요합니다. 운영 DB에는 적용하지 마세요.
7. npm ci / npm run dev / npm run build. 기존 Vercel의 Vite/npm run build/dist와 공개 환경2개를 유지합니다. api/와 server/를 누락하지 마세요. 고정 Production URL과 callback/Redirect URLs를 대조합니다.
8. 최초·취소·재로그인, 메일 없는 계정, TOTP 오답·지난 코드·재로그인 challenge, AAL1 차단/AAL2 본인 허용/타인 거절을 확인합니다. 실 SNS 계정 테스트와 어댑터 모의 테스트를 구분해 기록하세요.

이메일과 SNS는 인증 수단입니다. 과제 소유권은 Supabase user.id이며 같은 이메일만으로 과제나 역할을 옮기지 않습니다. 검증된 identity 연결은 Supabase가 처리합니다. 네이버 개발자 앱을 바꾸면 제공되는 앱별 id도 달라질 수 있습니다.

미완료 TOTP 등록만 기본 취소 UI로 지울 수 있습니다. 확인된 인증앱의 교체·분실은 본인 확인을 포함한 정식 관리/복구 절차가 필요합니다. 정책 해제·MFA 우회를 복구 방법으로 사용하지 않습니다.

완성본 검증 범위: 격리 로컬 Supabase의 실제 TOTP/AAL/RLS, OAuth SDK 인자와 네이버 어댑터 모의 검사. 실제 Google/Kakao/Naver 개발자 앱 승인·동의·callback은 각 수업 계정에서 최종 확인합니다.

상세 교재: https://vibe-coding-course-three.vercel.app/assignment-day4-lab.html
assignment-check-day4/solution/api/naver-userinfo.js
import {fetchNaverClaims,ProfileError} from '../server/naver-profile.js';
export default async function handler(req,res) {
  res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff');
  if(req.method!=='GET'){res.setHeader('Allow','GET');return res.status(405).json({error:'method_not_allowed'});}
  try{return res.status(200).json(await fetchNaverClaims(req.headers.authorization));}
  catch(error){return res.status(error instanceof ProfileError?error.status:502).json({error:'profile_verification_failed'});}
}
assignment-check-day4/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>SNS와 추가 인증 · 과제 체크 4일차</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<a class="skip" href="#main">본문으로 이동</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-day4-lab.html">4일차 실습 교재 ↗</a></header>
<main id="main" tabindex="-1">
<section class="hero" aria-labelledby="pageTitle"><div><p class="eyebrow">DAY 04 / SIGN IN, STAY SECURE</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="계정 로그인과 가입">
<section id="socialArea" class="panel social-panel" aria-labelledby="socialHeading"><h2 id="socialHeading">SNS 계정으로 시작하기</h2><p class="auth-note">수업용 제공자 설정을 마친 뒤 사용하세요. 과제는 로그인한 계정별로 관리됩니다.</p><div class="social-buttons"><button type="button" data-provider="google" disabled>Google로 로그인</button><button type="button" data-provider="kakao" disabled>Kakao로 로그인</button><button type="button" data-provider="custom:naver" disabled>Naver로 로그인</button></div></section>
<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>

<section id="mfaArea" class="panel mfa-panel" aria-labelledby="mfaTitle" hidden>
<div><p class="eyebrow">ONE MORE STEP</p><h2 id="mfaTitle">인증앱으로 한 번 더 확인해요.</h2><p>서비스 이용자의 추가 인증입니다. 과제는 인증을 마친 뒤 표시됩니다.</p></div>
<p id="mfaMessage" role="status" aria-live="polite"></p>
<img id="mfaQr" width="224" height="224" alt="본인 인증앱에 등록할 QR 코드. 스캔이 어려우면 아래 수동 키를 사용하세요." hidden>
<details id="mfaManual" hidden><summary>QR을 읽을 수 없다면 수동 키 보기</summary><p>본인 인증앱에만 입력하세요. 화면 캡처·과제물에 넣지 마세요.</p><code id="mfaSecret"></code></details>
<button id="mfaEnroll" type="button">인증앱 등록 시작</button>
<form id="mfaVerifyForm" action="#mfaArea" method="post" hidden><fieldset id="mfaFields" disabled style="border:0;padding:0;margin:0"><legend class="visually-hidden">인증앱 코드 확인</legend>
<div id="mfaFactorField" class="field" hidden><label for="mfaFactors">확인할 인증앱</label><select id="mfaFactors" name="factor"></select></div>
<div class="field"><label for="mfaCode">인증앱 코드 · 6자리</label><p class="hint" id="mfaHint">현재 인증앱의 숫자를 입력하세요. 앞자리 0도 포함합니다.</p><input id="mfaCode" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" spellcheck="false" autocapitalize="off" required aria-describedby="mfaHint"></div>
<button type="submit" class="primary">추가 인증 확인</button>
</fieldset></form>
<div class="mfa-actions"><button id="mfaCancel" type="button" hidden>미완료 등록 취소</button><button id="mfaReload" type="button">인증 상태 다시 확인</button></div>
<p class="auth-note">인증앱을 분실했다면 담당자의 계정 복구 절차를 이용하세요. 확인된 인증앱을 임의로 지우거나 MFA를 우회하지 않습니다.</p>
</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 04 · SNS 로그인 + TOTP MFA</p><p>본인 계정의 과제만 표시됩니다. 새로 불러오기로 최신 목록을 확인하세요.</p></footer>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
assignment-check-day4/solution/package-lock.json
{
  "name": "assignment-check-day4",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "assignment-check-day4",
      "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-day4/solution/package.json
{
  "name": "assignment-check-day4",
  "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-day4/solution/server/naver-profile.js
export class ProfileError extends Error { constructor(status){super('profile verification failed');this.status=status;} }
export function toNaverClaims(body) {
  const id=body?.response?.id;
  if(body?.resultcode!=='00'||typeof id!=='string'||!id.trim()||id.length>255)throw new ProfileError(502);
  // Naver의 검증된 앱별 고유 id만 사용합니다. 확인되지 않은 이메일을 연결 근거로 삼지 않습니다.
  return {sub:id};
}
export async function fetchNaverClaims(authorization,fetcher=fetch) {
  if(typeof authorization!=='string'||authorization.length>8192||!/^Bearer [A-Za-z0-9._~+\/-]+=*$/i.test(authorization))throw new ProfileError(401);
  let response;
  try {response=await fetcher('https://openapi.naver.com/v1/nid/me',{
    method:'GET',headers:{Authorization:authorization,Accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(5000)
  });}catch{throw new ProfileError(502);}
  if(!response.ok)throw new ProfileError([401,403].includes(response.status)?401:502);
  const reader=response.body?.getReader();if(!reader)throw new ProfileError(502);
  const decoder=new TextDecoder();let length=0,text='';
  try{while(true){const {done,value}=await reader.read();if(done)break;length+=value.byteLength;if(length>65536){await reader.cancel();throw new ProfileError(502);}text+=decoder.decode(value,{stream:true});}text+=decoder.decode();return toNaverClaims(JSON.parse(text));}
  catch{throw new ProfileError(502);}
}
assignment-check-day4/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-day4/solution/sql/mfa-required.sql
-- 3일차 tasks 소유자 정책·GRANT를 유지합니다. 새 수업용 DB에서만 실행하세요.
-- 먼저 가상 계정의 TOTP 등록·AAL2를 확인하세요. 적용 후 모든 사용자에게 MFA가 필요합니다.
begin;
create policy tasks_require_aal2 on public.tasks
  as restrictive for all to authenticated
  using (((select auth.jwt())->>'aal') = 'aal2')
  with check (((select auth.jwt())->>'aal') = 'aal2');
commit;
-- 정책 확인 (관리자 메타데이터 점검이며 실제 AAL1/AAL2 API 검사를 대신하지 않습니다.)
select policyname, permissive, cmd, qual, with_check
from pg_policies where schemaname='public' and tablename='tasks';
assignment-check-day4/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-day4/solution/src/main.js
import {createMfaController} from './mfa-controller.js';
import {startSocialLogin,readOAuthFailure} from './social-auth.js';
import { supabase } from './supabase-client.js';
import { createTaskStore } from './task-store.js';
const $=s=>document.querySelector(s);
const oauthFailed=readOAuthFailure(location);
let mfaReady=false,sessionInitialized=false;
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||!mfaReady;$('#refreshBtn').disabled=value||!session||!mfaReady;$('#sampleBtn').disabled=value||!session||!mfaReady;$('#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||!mfaReady||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||!mfaReady)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가 검사합니다.
const mfa=supabase?createMfaController(supabase,{root:$('#mfaArea'),onUnlocked:generation=>{
  if(generation!==epoch||!session||recovering)return;mfaReady=true;$('#appArea').hidden=false;authMessage('추가 인증을 확인했어요. 내 계정의 과제를 불러옵니다.');setBusy(false);refresh(generation);
}}):null;
function adoptSession(next,event=''){
  const changed=!sessionInitialized||session?.user.id!==next?.user.id||session?.access_token!==next?.access_token||event==='SIGNED_OUT'||event==='PASSWORD_RECOVERY';
  sessionInitialized=true;session=next;if(!changed)return;
  epoch++;readVersion++;mfaReady=false;items=[];removed=null;busy=false;
  resetEditor();$('#undoBtn').hidden=true;$('#taskSearch').value='';$('#filterAll').checked=true;message('');render();mfa?.reset();
  $('#authArea').hidden=!!next;$('#appArea').hidden=true;$('#accountArea').hidden=!next;$('#accountEmail').textContent=next?`${next.user.email||'SNS 계정'} · 나의 과제`:'';
  if(next){authMessage(recovering?'복구 링크를 확인했어요. 새 비밀번호를 설정하세요.':'로그인했어요. 이제 인증앱으로 추가 확인합니다.');const generation=epoch;setTimeout(()=>{if(generation===epoch&&!recovering)mfa.sync(next,generation);},0);}
  else authMessage(oauthFailed?'SNS 로그인이 취소되었거나 완료되지 않았어요. 다시 시도해 주세요.':'로그인하거나 회원가입한 뒤 인증앱으로 추가 확인해 주세요.');
  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)));}

const socialButtons=[...document.querySelectorAll('[data-provider]')];
for(const button of socialButtons){
  button.disabled=!supabase;
  button.addEventListener('click',async()=>{
    if(!supabase)return;socialButtons.forEach(b=>b.disabled=true);authMessage('선택한 제공자로 이동하고 있어요.');
    try{await startSocialLogin(supabase,button.dataset.provider,location.origin);}
    catch{authMessage('SNS 로그인을 시작하지 못했어요. 제공자 설정과 허용 주소를 확인해 주세요.');socialButtons.forEach(b=>b.disabled=false);}
  });
}
if(readOAuthFailure(location)){
  authMessage('SNS 로그인이 취소되었거나 완료되지 않았어요. 다시 시도해 주세요.');
  history.replaceState(null,'',location.pathname);
}
assignment-check-day4/solution/src/mfa-controller.js
export function createMfaController(client,{root,onUnlocked}) {
  const $=selector=>root.querySelector(selector);
  let context=null,revision=0,busy=false,factorId=null,pendingId=null;
  function note(text){$('#mfaMessage').textContent=text;}
  function clearSecret(){const image=$('#mfaQr');image.removeAttribute('src');image.hidden=true;$('#mfaSecret').textContent='';$('#mfaManual').hidden=true;$('#mfaManual').open=false;$('#mfaCode').value='';}
  function lock(value){busy=value;root.querySelectorAll('button,select').forEach(el=>el.disabled=value);$('#mfaFields').disabled=value;}
  function reset(){revision++;context=null;factorId=null;pendingId=null;busy=false;clearSecret();root.hidden=true;$('#mfaVerifyForm').hidden=true;$('#mfaEnroll').hidden=true;$('#mfaCancel').hidden=true;$('#mfaFactors').replaceChildren();note('');lock(false);}
  async function sync(session,generation){
    if(!session)return reset();const rev=++revision;context={session,generation};root.hidden=false;clearSecret();lock(true);note('추가 인증 상태를 확인하고 있어요.');
    try{
      const level=await client.auth.mfa.getAuthenticatorAssuranceLevel();if(level.error)throw level.error;
      const factors=await client.auth.mfa.listFactors();if(factors.error)throw factors.error;
      if(rev!==revision)return;
      if(level.data.currentLevel==='aal2'&&level.data.nextLevel==='aal2'){clearSecret();root.hidden=true;onUnlocked(generation);return;}
      const verified=factors.data.totp||[],pending=(factors.data.all||[]).find(f=>f.factor_type==='totp'&&f.status==='unverified');
      $('#mfaFactors').replaceChildren();
      verified.forEach((f,i)=>{const option=document.createElement('option');option.value=f.id;option.textContent=f.friendly_name||`인증앱 ${i+1}`;$('#mfaFactors').append(option);});
      factorId=verified[0]?.id||pending?.id||null;pendingId=verified.length?null:pending?.id||null;
      $('#mfaFactorField').hidden=verified.length<2;$('#mfaEnroll').hidden=!!factorId;$('#mfaVerifyForm').hidden=!factorId;$('#mfaCancel').hidden=!pendingId;
      note(verified.length?'인증앱의 6자리 코드를 입력해 로그인 절차를 마쳐주세요.':pending?'미완료 등록이 있어요. 이미 인증앱에 추가했다면 코드를 입력하고, 키를 잃었다면 미완료 등록을 취소하세요.':'인증앱을 등록하면 내 과제를 사용할 수 있어요. 등록 시작을 눌러주세요.');
    }catch{if(rev===revision){$('#mfaVerifyForm').hidden=true;$('#mfaEnroll').hidden=true;note('추가 인증 상태를 확인하지 못했어요. 다시 확인하거나 로그아웃 후 로그인해 주세요.');}}
    finally{if(rev===revision){lock(false);if(!$('#mfaVerifyForm').hidden)$('#mfaCode').focus();}}
  }
  $('#mfaEnroll').addEventListener('click',async()=>{
    if(!context||busy||factorId)return;const rev=revision;lock(true);
    try{
      const {data,error}=await client.auth.mfa.enroll({factorType:'totp',friendlyName:`과제 체크 ${Date.now()}`,issuer:'Assignment Check'});if(error)throw error;
      if(rev!==revision)return;
      factorId=pendingId=data.id;const qr=data.totp.qr_code;
      $('#mfaQr').src=qr.startsWith('data:image/svg+xml')?qr:`data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr)}`;$('#mfaQr').hidden=false;
      $('#mfaSecret').textContent=data.totp.secret;$('#mfaManual').hidden=false;$('#mfaEnroll').hidden=true;$('#mfaVerifyForm').hidden=false;$('#mfaCancel').hidden=false;
      note('본인 인증앱으로 QR을 스캔한 뒤 6자리 코드를 입력하세요. QR·수동 키는 공유하지 마세요.');$('#mfaCode').focus();
    }catch{if(rev===revision)note('등록을 시작하지 못했어요. 기존 등록 상태를 다시 확인해 주세요.');}
    finally{if(rev===revision){lock(false);if(!$('#mfaVerifyForm').hidden)$('#mfaCode').focus();}}
  });
  $('#mfaFactors').addEventListener('change',()=>{factorId=$('#mfaFactors').value;$('#mfaCode').value='';});
  $('#mfaVerifyForm').addEventListener('submit',async event=>{
    event.preventDefault();if(!context||busy||!factorId)return;const rev=revision,current=context,code=$('#mfaCode').value.trim();if(!/^[0-9]{6}$/.test(code))return;
    lock(true);
    try{
      const {error}=await client.auth.mfa.challengeAndVerify({factorId,code});if(error)throw error;
      if(rev!==revision)return;clearSecret();await sync(current.session,current.generation);
    }catch{if(rev===revision){$('#mfaCode').value='';note('코드를 확인하지 못했어요. 인증앱의 최신 코드와 기기 시간 설정을 확인해 주세요.');$('#mfaCode').focus();}}
    finally{if(rev===revision){lock(false);if(!$('#mfaVerifyForm').hidden)$('#mfaCode').focus();}}
  });
  $('#mfaCancel').addEventListener('click',async()=>{
    if(!context||busy||!pendingId)return;const rev=revision,current=context;lock(true);
    try{const {error}=await client.auth.mfa.unenroll({factorId:pendingId});if(error)throw error;if(rev!==revision)return;clearSecret();factorId=pendingId=null;await sync(current.session,current.generation);}
    catch{if(rev===revision)note('미완료 등록을 취소하지 못했어요. 상태를 다시 확인해 주세요.');}
    finally{if(rev===revision){lock(false);if(!$('#mfaVerifyForm').hidden)$('#mfaCode').focus();}}
  });
  $('#mfaReload').addEventListener('click',()=>{if(context&&!busy)sync(context.session,context.generation);});
  reset();return {reset,sync};
}
assignment-check-day4/solution/src/social-auth.js
export const SOCIAL_PROVIDERS = Object.freeze(['google','kakao','custom:naver']);
export async function startSocialLogin(client,provider,origin) {
  if(!SOCIAL_PROVIDERS.includes(provider))throw new Error('unsupported provider');
  const redirectTo=new URL('/',origin).href;
  const {error}=await client.auth.signInWithOAuth({provider,options:{redirectTo}});
  if(error)throw error;
}
export function readOAuthFailure(location) {
  const query=new URLSearchParams(location.search),hash=new URLSearchParams(location.hash.replace(/^#/,''));
  return query.has('error')||query.has('error_code')||hash.has('error')||hash.has('error_code');
}
assignment-check-day4/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-day4/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-day4/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}}

.social-panel{margin-bottom:22px}.social-buttons{display:flex;gap:10px;flex-wrap:wrap}.social-buttons button{flex:1;min-width:150px;font-weight:650}.social-buttons [data-provider=kakao]{background:#f9e877;color:#2d2610}.social-buttons [data-provider="custom:naver"]{background:#e2f5e2;color:#174d2a}

.mfa-panel{max-width:620px;margin:0 auto 26px}.mfa-panel>div>p:not(.eyebrow),.mfa-panel>p{font-size:14px;line-height:1.8}.mfa-panel img{max-width:100%;height:auto;background:white;border-radius:12px;padding:10px}.mfa-panel form{margin-top:18px}.mfa-panel select{width:100%;min-height:48px;padding:10px;border:1px solid var(--line);border-radius:10px;font:inherit}.mfa-panel details{margin:15px 0}.mfa-panel summary{cursor:pointer;min-height:48px;padding:12px 0}.mfa-panel code{display:block;overflow-wrap:anywhere;padding:12px;background:#edf3e8;border-radius:8px}.mfa-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:15px}.visually-hidden{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}
assignment-check-day4/starter/.env.example
VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_REPLACE_WITH_YOUR_KEY
assignment-check-day4/starter/.gitignore
node_modules/
dist/
.env
.env.*
!.env.example
.vercel/
assignment-check-day4/starter/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-day4/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>나의 과제, 어디서든 · 과제 체크 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-day4/starter/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-day4/starter/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-day4/starter/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-day4/starter/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-day4/starter/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-day4/starter/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-day4/starter/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-day4/starter/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-day4/student-teacher-steps.txt
D01 전날 앱·세션·callback 왕복 정리
학생: 본인3일차 앱에서 이메일 로그인과 과제 저장을 확인한다. 깨끗한 main에서 feature/social-mfa 브랜치를 만든다. 앱→제공자→Supabase callback→앱 루트→세션→내 과제 순서를 그린다. starter는 전날 코드, checkpoint-sns는 SNS 중간 코드, solution은 MFA까지 포함한 완성 코드다.
git status -sb
git switch main
git pull --ff-only origin main
git switch -c feature/social-mfa
npm ci
npm run dev
확인: 새 수업용 개발자 앱과 테스트 사용자를 준비한다. 제공자 callback은 Supabase 화면에서 복사하고, 앱의 복귀 루트는 Supabase Redirect URLs에 등록한다. 두 주소를 서로 바꾸지 않는다.
강사: 1교시50분은 도입8·공통설정10·Google20·짝검증8·정리4다. 개발자 앱 생성·승인·도메인/개인정보 URL은 강사가 사전 준비한다. 수업시간은 코드 연결과 실제 왕복 검증에 쓴다. 완성본은 MFA까지 요구하므로 SNS만 확인할 때는 중간 코드를 사용한다.

D02 Google Cloud와 Google 로그인 연결
학생: Google Cloud의 수업 프로젝트에서 Google Auth Platform의 Branding·Audience·Data Access와 테스트 대상 사용자를 확인한다. 웹 OAuth Client의 Authorized redirect URI에 Supabase Google 설정의 Callback URL을 넣는다. Client ID·Secret은 Supabase Google provider에 등록하고 요청1로 버튼을 연결한다.

확인: 기본 로그인에 필요한 정보만 요청한다. 첫 로그인·동의 취소·재로그인을 확인하고 Supabase user.id/identities와 기존 과제를 비교한다. Google 계정의2단계 인증이 Supabase AAL2를 자동 의미하지는 않는다.
강사: Supabase가 외부 code·token 처리를 담당하고 Vite 앱은 PKCE 복귀 후 세션을 읽는다. 이 예제는 앱 루트로 돌아오므로 Next.js용 /auth/callback 서버 경로를 그대로 복사하지 않는다. 사용자 대상·테스트 모드·검증 상태에 따라 계정별 결과가 달라질 수 있다.

D03 Kakao Developers와 카카오 로그인 설정
학생: Kakao Developers의 수업 앱에서 웹 서비스 정보와 카카오 로그인 활성화를 확인한다. App Settings→App→Platform Key에서 사용할 REST API 키와 Kakao Login Client Secret을 확인·활성화하고, 해당 키의 Kakao Login Redirect URI에 Supabase callback을 등록한다. Supabase Kakao provider에 두 값을 넣고 버튼을 연결한다.

확인: REST API 키는 client_id, Kakao Login Client Secret은 client_secret이다. JavaScript 키·Admin 키를 대신 쓰지 않는다. 제공자 동의 항목은 앱이 실제 필요한 범위로만 요청한다.
강사: 2교시50분은 흐름8·Kakao18·가입/identity12·짝검증8·정리4다. 메뉴명은 제공자 업데이트에 따라 바뀔 수 있어 공식 문서와 실제 화면을 함께 확인한다. 키 이름이 비슷해도 용도가 다르다는 점을 실제 설정표로 보여준다.

D04 이메일 누락·취소·기존 회원 연결 검사
학생: 카카오 동의 화면을 취소하거나 선택 정보 제공을 거절해 본다. 이메일 정보가 없을 때도 필요한 서비스라면 Supabase Kakao 설정의 Allow users without an email을 확인한다. 이 수업 앱은 연락 이메일 대신 user.id로 과제 소유권을 관리한다. 이메일 계정과 SNS의 identity 연결 여부를 실제 계정에서 확인한다.

확인: 없는 이메일을 만들거나 확인되지 않은 메일을 verified로 표시하지 않는다. 이메일이 같아도 클라이언트에서 계정/역할/과제를 합치지 않는다. Supabase의 확인된 identity 연결 결과를 확인하며, 별개 uid라면 과제도 별개다.
강사: 이메일 필수 연락 서비스는 별도 확인 단계를 설계한다. 네이버 F19의 고급 가입 흐름과 이번 단순 과제 앱을 구분한다. 수동 identity 연결은 로그인된 본인 계정에서 별도 설계하는 확장 과제다. SNS 공급자를 바꾼다고 관리자 역할이 생기지 않는다.

D05 네이버 Custom OAuth와 서버 어댑터 배포
학생: Naver Developers에 서비스 URL·callback·테스트 계정을 등록한다. Supabase Custom OAuth Provider를 Manual OAuth2·custom:naver로 구성하고 생성 화면의 Callback URL을 네이버에 복사한다. Authorization/Token URL과 Client ID·Secret을 설정한다. checkpoint-sns의 api/와 server/를 포함해 본인 고정 Vercel 수업 앱에 배포하고 UserInfo URL을 /api/naver-userinfo로 지정한다.

확인: UserInfo URL은 Supabase가 호출 가능한 HTTPS 서버 주소다. localhost·Vite 정적 서버·인증 보호된 Preview 주소를 그대로 넣지 않는다. GET 무토큰 호출이401이면 라우트 응답은 확인한 것이며 실제 네이버 로그인을 확인한 것은 아니다.
강사: 3교시50분은 흐름8·어댑터20·Custom OAuth10·짝검증8·정리4다. 강사는 어댑터 배포와 Custom Provider 사용 가능 여부·한도·테스트 대상을 미리 확인한다. 로컬 Vite는 api 함수를 실행하지 않으므로 Vercel dev 또는 사전 배포한 수업 어댑터로 시험한다. 보호 기능을 끄는 방식으로 Preview 문제를 우회하지 않는다.

D06 네이버 id→sub와 이메일 없는 계정 검증
학생: 요청2로 서버 어댑터의 고정 네이버 프로필 조회와 응답 검증을 구현한다. resultcode=00과 response.id를 확인한 뒤 sub만 반환한다. 이 제공자에 email_optional=true를 설정하고 custom:naver 버튼을 연결한다. 취소·토큰 오류·이메일 없음·재로그인에서 동일 네이버 identity가 유지되는지 확인한다.

확인: 브라우저가 임의로 보낸 사용자 JSON을 믿지 않는다. 네이버 response.id는 해당 개발자 앱의 고유 식별자다. Client ID를 바꾸면 같은 개인이어도 연결 결과가 달라질 수 있다. 이메일 자동 확인을 전역으로 해제하지 않는다.
강사: 어댑터가 토큰을 읽는 이유는 네이버 서버에 확인하기 위해서다. 토큰을 화면·로그·응답에 되돌려주지 않는다. 공식 문서의 PKCE 지원 조건과 실제 제공자 응답을 확인하고, 실패할 때 state/nonce/검증 절차를 임의로 빼지 않는다. 실계정 왕복이 끝나지 않았다면 “설정 완료/모의 통과/실계정 미확인”으로 기록한다.

D07 인증앱 등록→6자리 검증→AAL2
학생: Supabase의 서비스 이용자 Auth MFA에서 TOTP 등록·검증을 활성화한다. 요청3으로 MFA 화면을 추가한다. 수업용 가상 계정으로 로그인해 등록 시작을 누르고 본인 인증앱에서 QR 또는 수동 키를 등록한다. 먼저 틀린 코드로 거절을 확인한 뒤 최신6자리 코드로 검증한다. 등록만 된 상태와 AAL2 세션을 구분한다.

확인: TOTP 입력은 문자열로 받아 앞자리0을 보존한다. 등록 성공 뒤 QR·키·입력 코드는 화면에서 제거한다. 코드를 자동 제출하지 않고 확인 버튼으로 검증한다. 이 설정은 Supabase 관리자 대시보드 MFA와 다르다.
강사: 4교시50분은 흐름8·MFA18·DB강제12·짝검증8·정리4다. 등록 버튼을 반복해 factor를 만들지 않도록 한다. 수업 화면 공유에는 실제 QR·수동 키·코드를 띄우지 않는다. 확인된 인증앱의 삭제/기기 교체는 정식 관리·복구 흐름의 확장 과제로 다루고 기본 예제는 미완료 등록 취소만 제공한다.

D08 DB AAL2 강제·재로그인·Vercel 최종 검증
학생: 가상 계정의 AAL2 성공을 먼저 확인한 뒤 본인 수업 DB에서 sql/mfa-required.sql을 실행한다. 기존 user_id 소유자 정책을 유지한다. 로그아웃·재로그인하면 AAL1과 추가 인증 화면이 나오는지 검사한다. Data API에서 AAL1은 차단되고 AAL2 본인만 CRUD가 가능한지 A/B 계정으로 확인한다. Git PR 검토·병합과 Vercel 배포 후 같은 검사를 반복한다.
npm run build
git status -sb
git add index.html styles.css src api server sql package.json package-lock.json README.md
git diff --cached
git commit -m "Add social login and TOTP MFA"
git push -u origin feature/social-mfa
확인: AS RESTRICTIVE를 빠뜨리지 않는다. MFA 조건과 소유자 조건을 모두 만족해야 한다. 정책 적용 뒤 A/B 모두 MFA가 필요하다. 결과물에는 PR·배포commit·공개URL·제공자별 최초/취소/재로그인·MFA 전후 검사 결과를 기록하되 키·토큰·QR·코드는 넣지 않는다.
강사: AAL2는 추가 인증 강도이며 관리자 권한이 아니다. 화면 숨김과 실제 DB 거절을 각각 확인한다. 인증앱 분실은 신원 확인을 포함한 정식 복구 절차로 안내하고 RLS나 MFA를 꺼서 통과시키지 않는다. SNS 실계정의 승인·동의·callback과 선택 보충 복구 경로는 수업 계정에서 따로 리허설한다.