서버 API·인증·계정 연결·삭제·관리자 경계 개선 — 공용 경계 한 곳, 명시 결과, 앱 쪽 거래 대조 — v0.18.0 B01 (2026-09-07)
- 기간: 2026-09-07 (세션 2개 —
78b1ed14Phase 1~6 수정,9161286bPhase 6 마무리·머지, 오너 지시 "#1331 진행해줘" → "이어서 진행해줘 묻지말고 phase 끝까지 완주"). 계획 ID B01 / Phase 2 Step 2-1. 총괄 B01로 돌아가기. - 랜딩: PR #1346 → main
ba0f4035(2026-09-07 20:01 KST squash, Phase 1~5 커밋be44443e·ac7121eb·a2382b66·209e8062·9ab5472d·d18994d8·e5e35cbc+ Phase 6 수정caa23373·165931e1) — 마이그레이션·엣지 함수 없음, 랜딩 큐 불필요(일반 절차). Vercel 함수(api/**)·앱 서비스 변경이라 main 머지 = staging 배포(Deploy run 34114479010 초록(database·functions·frontend·smoke 성공)), Production 은 v0.18.0 릴리스. - 설계서: 이슈 #1331 착수 댓글(문제 10건·해결 방안·Phase 6개·예상 효과·개선사항 표).
- 정본: 인증·계정·관리자 서버 API 계약 · 서버 공용 경계
api/auth/_shared.js· 앱src/react/services/auth/{authErrors,nativeAuthTransactions,accountDeletionResult,impersonationSession}.ts. - 도구: 로컬 Supabase 샌드박스 실측 프로브(세션 scratchpad
probe-phase2.mjs, 레포 밖) · 테스트 도우미tests/support/serverApi.mjs(가짜 요청/응답·fetch 규칙표·환경변수 격리). - 게이트: 새 테스트 9파일 54건(
tests/auth/{serverApiShared,accountDeleteApi,adminApi,testAdminSessionApi,accountDeletionResult,impersonationSession,nativeAuthTransactions,nativeAuthCallbackBridge,authErrors}.test.mjs) + 회귀 1건(본문 도착 뒤 읽기, Phase 6) + 기존 인증 테스트 유지 ·npm run check(Phase 마다) ·npm run ci:local --full(Phase 6, 검증 줄은 PR #1346 본문) · CI 1회 초록(run 34112624284, browser-journeys 4샤드 포함) · 명부 신고 1건(authPkceHardCut앵커 재지정). - 버그리포트: 없음(오너 보고 결함이 아니라 계획된 구조 개선).
- 계약: 새 문서
docs/contracts/auth-account-api.md§1~§9. 기존social-auth.md·native-bridge.md·identity-passing.md·app-role-privileges.md는 변경 없음.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 서버 API 공용 경계 api/auth/_shared.js + handler 4개 계약 테스트 | ✅ be44443e |
| Phase 2 | 계정 삭제 재개 경계 — 410 account_already_deleted·행위자 검증(amr)·앱 응답 해석 한 곳 | ✅ ac7121eb (샌드박스 실측) |
| Phase 3 | 시험 관리자 API Production 차단·상수 시간 비밀 비교, 열람 종료 시 대상 세션 서버 폐기 | ✅ a2382b66 |
| Phase 4 | 앱 쪽 네이티브 인증 거래 장부, 인증 오류 분류 모듈 분리 | ✅ 209e8062·9ab5472d |
| Phase 5 | 계약 문서·작업 기록·장부 | ✅ 이 문서 |
| Phase 6 | ci:local --full → 브라우저 e2e 가 잡은 결함 1건 수리 → PR → CI 1회 → 머지 → staging 확인 | ✅ caa23373·165931e1 → main ba0f4035 |
1. 배경
v0.18.0 은 서버가 정본 데이터를, 앱이 편집 중 상태와 미전송 쓰기를 책임지는 구조로 간다. 그중 인증·계정 연결·삭제·관리자 열람은 React 데이터 흐름 밖(Vercel 함수 api/** + SDK 배선 supabaseAuth.ts)에 있어 A01 의 repository 분리로는 정리되지 않았고, 다음 스텝 A07(owner·auth runtime)이 소비할 "누가·어떤 결과·어떤 실패" 계약이 없었다. G05 장부는 이 영역을 B01 소유로 두고 있었다.
2. 문제 제기
서버 공용 도우미의 자리가 없어 handler 마다 입력·오류 처리가 달랐다
계정 삭제 API 가 관리자 전용 모듈에서 JSON 응답·토큰·본문 읽기를 빌려 쓰고, 시험 관리자 API 는 같은 함수의 복사본을 가졌다(본문 읽기 3벌, 상한 2048/4096). 본문이 상한을 넘으면 연결만 끊고 'end' 만 기다려 함수 시간 초과까지 매달렸고, 외부 호출(Supabase·Kakao·Apple·스토리지)에 시간 제한이 없었다.
삭제가 끝났는데 응답이 유실되면 재시도가 "실패"로 보였다
서버는 "토큰 없음/무효"와 "토큰은 유효한데 유저가 이미 없다"를 구분하지 않아 재시도가 401 로 끝났다 — 화면은 "삭제하지 못했어요", 기기에는 죽은 세션이 남았다.
삭제 행위자 검증이 앱에만 있었다
관리자 열람 세션은 대상 유저의 진짜 토큰이라 서버는 구분하지 못했고, 앱의 읽기 전용 가드가 유일한 방어였다.
시험 관리자 로그인의 환경 차단이 환경변수 한 개 + 비밀뿐이었다
Production Supabase 를 가리키는 서버에서 플래그가 켜지면 비밀 하나로 관리자 계정이 만들어지고 로그인됐다. 비밀 비교도 길이 누설형이었다.
관리자 열람을 끝내도 대상 세션이 서버에 살아 있었다
앱이 관리자 세션으로 되돌릴 뿐 대상의 refresh token 을 폐기하지 않았다.
네이티브 콜백의 거래 대조가 네이티브 한쪽에만 있었다
앱은 자기가 만든 거래 ID 를 기억하지 않아 돌아온 콜백의 ID 형식만 검사했다.
오류 분류가 1,745줄 파일 안에 흩어져 있고 계약 문서가 없었다
3. 해결 방안
원칙 (오너 결정)
이번 트랙에 오너 결정은 없다. 전제: "#1331 진행해줘" 를 착수 승인으로 봤다(이슈 범위는 총괄·HQ 승인 완료). 보존 정책 P10(owner/RLS/grants·삭제 연쇄·명시 신원 전달)은 그대로다.
접근
| 안 | 내용 | 판정 |
|---|---|---|
| 증상별 조건문 | 각 handler 에 재시도 401 예외·본문 상한 응답만 추가 | 기각 — 공용 자리가 없어서 생긴 문제라 다음 endpoint 가 또 빠뜨린다 |
| 서버 공용 경계 + 명시 결과 (채택) | api/auth/_shared.js 를 공용 경계로 세우고 admin/account 는 자기 것만. 삭제는 단계별 명시 결과. 시험·관리자 API 는 코드가 대상 환경·세션 발급 방법을 본다 | 채택 |
| 앱 provider 흐름 (채택) | 네이티브 거래 장부를 순수 모듈로, 오류 분류를 authErrors.ts 로 분리. provider 별 차이는 정의 3파일에만 | 채택 |
행위자 판정을 허용 목록(oauth·password 만 허용)으로 | 실 provider 의 amr 값을 샌드박스에서 잴 수 없어 실사용자 삭제를 막을 위험 | 기각 — 실측한 값(otp)만 거부하는 거부 목록 |
4. 적용한 내용
Phase 1 — 서버 공용 경계 (be44443e)
api/auth/_shared.js: 본문 읽기(4KB 상한 → 413, 깨진 JSON → 400, 끊김 → 400, 플랫폼이 먼저 읽은req.body수용), JSON 응답, bearer, Supabase 서버 설정, 서비스 헤더,fetchWithTimeout(Supabase 8초·provider 6초,UpstreamTimeoutError),verifyCaller(401/503 명시 결과 + 인증 서버 error_code), 오류 로그 키 차단.api/admin/_shared.js는 관리자 전용(권한 판정·유저 검색)만.api/account/delete.js가 관리자 모듈 의존을 끊고, 시험 관리자 API 의 복사본 삭제. Apple/Kakao 모듈에 시간 제한 인자.tests/support/serverApi.mjs+ handler 4개 계약 테스트 30건.
Phase 2 — 계정 삭제 재개 경계 (ac7121eb)
- 서버: 토큰 유효·유저 없음(
user_not_found) →410 account_already_deleted.amr이 전부otp인 세션 →403 impersonated_session. 단계별retryable. - 앱:
services/auth/accountDeletionResult.ts(200/410 = 삭제됨, 나머지 실패·재시도 가능 여부) →accountDeletionClient가 소비. - 샌드박스 실측 3건(아래 §5).
Phase 3 — 관리자·시험 API 차단 (a2382b66)
- 시험 관리자 API: Production ref(
kobxeylancdimqhfkbnl, deploy.yml 과 대조) 이면 404.crypto.timingSafeEqual. services/auth/impersonationSession.ts:logout?scope=local로 대상 세션 폐기 →stopImpersonation이 관리자 세션 복원 전에 호출.
Phase 4 — 앱 provider 흐름 (209e8062·9ab5472d)
services/auth/nativeAuthTransactions.ts: begin/accept/complete, TTL 10분, 소비 목록 8.supabaseAuth가 시작 시 등록·콜백 시 대조(LG_SOCIAL_AUTH_STALE_CALLBACK신설, duplicate 는 무시).services/auth/authErrors.ts:PublicSocialAuthError·isRetryableAuthError·isAuthSessionError이전 +classifyAuthError. 공개 API 불변.- 실제
supabaseAuth배선 행동 테스트(nativeAuthCallbackBridge).
Phase 5 — 문서·장부
docs/contracts/auth-account-api.md(§1 유저 이야기·§2 endpoint 4개 표·§3 삭제 단계/재개·§4 상태 전이·§5 보존 정책·§6 오류 분류·§7 비밀 점검·§8 인계·§9 검증), 이 기록, 사이드바·README 등록, 장부 규칙·재생성.
Phase 6 — 검증·수리·랜딩 (caa23373·165931e1 → ba0f4035)
npm run ci:local --full: verify·db reset·schema 스냅샷·pgTAP 109파일/1899 assert·e2e-local 11/11·e2e-empty 7/7·e2e-cardio 6/6·e2e-persistence 1/1 통과. 브라우저 묶음은 4173 포트를 다른 세션이 써서 같은 샌드박스·같은 환경변수로 4179 미리보기에 대고 따로 돌렸다.- 브라우저 e2e CASE-017(계정 삭제 왕복)이 실제 결함을 잡았다. 새 본문 읽기가 "이미 소비된 스트림"을
req.complete로 판정했는데, 실제 Node 서버는 본문이 도착만 하면(아직 읽지 않았어도)complete를 true 로 둔다. 이 API 들은 호출자 검증(네트워크)을 먼저 기다리므로 그 사이 도착한 본문을 버려400 confirmation_required가 났다. 단위 테스트의 가짜 요청은 이 상태를 만들지 않아 통과했다. vite 미리보기에 실제 요청을 보내 재현 →readableEnded만 보도록 수정 → 같은 재현이 200 → 410 으로 정상 → 회귀 테스트 추가(caa23373). CASE-017 의 재요청 기대도 옛 계약(401)에서 새 계약(410)으로 갱신. - 원격 CI 첫 head 에서 unit-tests 가 빨간불:
fetchWithTimeout의 시간 제한 타이머에unref를 걸어 두었는데 CI 의 Node 22 에서는 그 타이머가 프로세스를 붙들지 않아 abort 가 일어나기 전에 테스트가 끝났다.unref제거(165931e1) → CI 전부 초록(run 34112624284, scope=full). 최종 브라우저 묶음 3차: 37 passed + flaky 1(CASE-028, 이 변경과 무관, 재시도 통과) · viewport 14/14. - squash 머지
ba0f4035→ 기본 체크아웃 fast-forward → staging Deploy run 34114479010 초록(database·functions·frontend·smoke 성공).
주요 결정과 그 근거
- 거부 목록 행위자 판정: 실측 가능한 값(
otp)만 거부. 실사용자의amr(소셜 로그인)은 샌드박스에서 만들 수 없으므로 허용 목록은 쓰지 않는다. - 410 은 "완료": 서버가 이미 지운 계정에 대한 재시도는 사용자 관점에서 첫 시도와 같아야 한다. 200 과 같은 기기 정리를 한다.
- SDK signOut 대신 서버 logout 직접 호출: 열람 종료에서 SDK 의 signOut 은 로컬 세션까지 지우고 SIGNED_OUT 을 내 셸이 로그아웃 화면으로 간다. 필요한 건 서버 폐기뿐.
- 거부된(stale) 콜백은 진행 중 거래를 살려 둔다: 올바른 콜백이 아직 올 수 있다. 그 밖의 실패는 거래를 닫는다.
- 장부가 비었으면 네이티브 검증을 신뢰: 콜백 도중 WebView 재시작(#382 이후 iOS 는 이 경로가 정상)에서 로그인을 막지 않는다.
작업 중 드러난 것
- 시간 제한 판정은
AbortError도 시간 초과로 본다(우리가 넘긴 signal 뿐이므로) — 처음엔signal.aborted만 봐서 테스트 스텁에서 502 로 새는 것이 잡혔다. - 테스트용 가짜 요청은 실제 Node 스트림처럼 'data' 리스너가 붙기 전엔 멈춰 있어야 했다 — handler 가 본문보다 호출자 검증(네트워크)을 먼저 기다리기 때문. 샌드박스 프로브에서 매달려 발견.
- 명부 등재 테스트(
authPkceHardCut) 변경 신고를pending-changes.json에 새 항목으로 넣었더니 기존 항목과 중복으로 거부됐다 — 같은 파일의 기존 사유에 이어 적는다. Phase 4 보고에 백그라운드 실행의 요약 알림만 보고 "통과" 를 적었다가 로그의 종료 코드 1 을 보고 정정했다(이슈 댓글). - 이 PC 의 로컬 Supabase 스택(
ci:local --full --only db)은 pgTAP 뒤에도 살아 있어 handler 를 실제 Auth 에 대고 돌릴 수 있다(supabase status -o env로 키). - 서버 API 를 고치면 단위 테스트만으로는 부족하다 — Node
IncomingMessage.complete는 본문 도착만으로 true 가 되는데 가짜 요청은 그 상태를 흉내내지 못했다.ci:local --full의 브라우저 묶음(CASE-017 이 실제api/account/delete.js를 vite 미들웨어로 부른다)이 잡았다. 소비 판정은readableEnded만 본다. - CI 빨간불 분류(§20): unit-tests 1회 실패(
fetchWithTimeout타이머unref)는 로컬 재현 불가(환경 차이) — 이 PC 의 Node 에서는 통과했고 CI 의 Node 22 에서만 abort 가 일어나지 않았다. 브라우저 e2e CASE-017 실패는 로컬에서 먼저 재현·수정해 CI 에는 올라가지 않았다.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 서버 handler 단위 테스트 | 0 → 4파일 39건(전 분기) |
| 본문 상한 초과 | 응답 없이 매달림 → 413 body_too_large 즉시 |
| 외부 호출 시간 제한 | 없음 → Supabase 8초·provider 6초, 504 *_timeout |
| 삭제 완료 뒤 응답 유실 → 재시도 | 401 실패 표시 → 410 완료 처리 + 기기 정리 (샌드박스 실측 200 → 410) |
| 관리자 열람 세션의 삭제 요청 | 앱 가드만 → 서버 403 impersonated_session (샌드박스 실측, 유저 유지) |
| 시험 관리자 로그인이 Production 프로젝트를 가리킬 때 | 플래그 의존 → 코드가 404 |
| 열람 종료 뒤 대상 refresh token | 유효 유지 → 서버 폐기(실측 204 → refresh 400) |
| 네이티브 콜백 대조 | 네이티브만 → 앱 장부 + 네이티브(배선 테스트: 다른 거래 거부·교환 0회, 진짜 거래 교환 1회, 중복 무시) |
| 인증 오류 분류 | supabaseAuth.ts 안 → authErrors.ts + classifyAuthError(A07 소비) |
supabaseAuth.ts | 1,745줄 → 1,758줄(오류 분류·공개 오류 클래스 약 45줄을 authErrors.ts 로 꺼내고, 거래 장부·열람 세션 폐기 배선과 설명 주석이 늘었다 — 줄 수 감축이 목적이 아니라 정책 함수를 SDK 배선 밖으로 낸 것) |
| 전체 게이트 | npm run check 2,924 pass / 0 fail / 14 skipped (Phase 4 재실행) |
| 실 provider 시험 | 미실행 — R02·R05 인계(계약 §8) |
| main/staging | PR #1346 squash → main ba0f4035 · CI run 34112624284 전부 초록 · staging Deploy run 34114479010 초록(database·functions·frontend·smoke 성공) · Production 은 v0.18.0 릴리스에서 |
6. 이번 개선으로 향상된 것
삭제를 누른 사용자가 네트워크가 끊겨도 "삭제됨"을 본다
서버가 이미 지운 계정에 대한 재시도는 완료로 끝나고 기기 정리까지 이어진다. 죽은 세션이 남지 않는다.
운영 실수 두 가지가 코드로 막힌다
Production 을 가리키는 서버의 시험 관리자 로그인은 열리지 않고, 관리자 열람 세션으로는 남의 계정을 지울 수 없으며 열람이 끝나면 그 세션은 죽는다.
다음 endpoint·다음 담당이 시작할 자리가 있다
서버 API 는 공용 경계 한 파일에서 시작하고, A07 은 계약 문서 §5·§6 을 그대로 소비한다.
구조적으로 남는 것
api/auth/_shared.js— 새 endpoint 의 시작점(본문·응답·호출자·시간 제한·로그).- 계정 삭제 단계·재개 표(계약 §3)와 보존 정책 표(§5).
- 네이티브 거래 장부 계약(§4-3): 앱이 자기 거래를 대조한다.
- 인증 오류 분류 모듈과 공개 오류 코드 목록(§6).
- handler 를 네트워크 없이 돌리는 테스트 도우미(
tests/support/serverApi.mjs).
남은 것
- 실 provider(Kakao unlink·Apple 재인증 revoke·실계정 로그인/연결) 시험 — 승인된 시험 계정으로 R02·R05 가 실행(계약 §8).
- 관리자 열람 세션의
amr판정을 다른 쓰기 경계에도 넣을지 — A05. - v0.18.0 릴리스 뒤 Production 확인: 삭제 API 410/403 발생 여부(서버 로그
account_delete_rejected_impersonated_session), 시험 관리자 차단 로그 0 → 이슈 #1331[v0.18.0 반영완료]+ 닫기.