# task-2999 — A-39 push_subscriptions 익명 소유권 부재 (DDL + 프론트 전환 묶음)

**레벨**: Lv.2 (보안) · **팀**: dev3-team (다그다) · **level=security** · **회장 승인 완료** (2026-08-22)

## 배경 — ANU 가 프로덕션에서 직접 재현했다
task-2996(PR #243)에서 SQL 쌍은 main 에 머지됐으나 **DDL 미적용** 상태다.

**ANU 프로덕션 실측 (2026-08-22 15:00 KST)**
```
public.push_subscriptions 정책 6건 — anon 3건 그대로:
  INSERT {anon}  CHECK(subscriber_type = 'customer')
  UPDATE {anon}  USING/CHECK(subscriber_type = 'customer')
  SELECT {anon}  USING(subscriber_type = 'customer')
chat_register_push_subscription RPC = 0건   → DDL 미적용 확정
행 수 = 0
```

**취약점 라이브 재현** (ANU 자작 프로브, 트랜잭션 후 ROLLBACK — 데이터 변화 0):
```
SET LOCAL ROLE anon;
INSERT INTO push_subscriptions(conversation_id, endpoint, p256dh, auth, subscriber_type)
VALUES ('<타인 소유 대화 id>', 'https://probe.invalid/anu', 'p', 'a', 'customer');
→ INSERT 성공. 익명이 타인 대화의 푸시 구독을 생성할 수 있다.
```
★ 이 프로브가 성공했다는 것은 **정책 경로가 정상 동작 중**이라는 뜻이다.
따라서 **0행은 "조용히 고장나 있다"가 아니라 "아직 아무도 안 썼다"**로 해석한다.
(이 해석 차이가 작업 순서를 가른다. 전제로 삼아라.)

---

## ★★★ 먼저 읽어라 — ANU 가 찾은 함정 2개

### 함정 1: RPC 시그니처와 프론트 시그니처가 안 맞는다
```
main 의 RPC (20260822T000003):
  chat_register_push_subscription(p_token text, p_endpoint text, p_p256dh text, p_auth text)
                                  ^^^^^^^ 토큰을 요구한다

src/lib/push-utils.ts:16:
  registerPushSubscription(conversationId: string, subscriberType: 'customer'|'agent')
                           ^^^^^^^^^^^^^^ 토큰이 없다
```
**그냥 바꿔 끼울 수 없다.** 고객 경로가 토큰을 어디서 얻는지 먼저 확인하라
(고객 상담 진입은 `customer_chat_tokens` 기반이다. `CustomerChat` 경로를 추적하라).
토큰을 확보할 수 없는 호출부가 있으면 **임의로 만들지 말고 ANU 에 보고**하라.

### 함정 2: 같은 함수를 설계사(agent)도 쓴다 — 전면 전환하면 설계사가 깨진다
```
src/pages/CrmMessenger.tsx:123   registerPushSubscription(selectedConv.id, 'agent')
src/components/PushNotificationToggle.tsx:67  registerPushSubscription(conv…, …)
```
`agent` 는 로그인한 `authenticated` 사용자이고, 그 경로는 별도 정책 3건
(`Agents can insert/read/update own push subs`)이 이미 담당한다. **anon 문제와 무관하다.**
RPC 는 고객(customer)용 설계다.

**요구사항**: `customer` 경로만 RPC 로 보내고, `agent` 경로는 기존 `authenticated` 직접 호출을 유지한다.
둘을 한 함수가 처리한다면 내부에서 분기하라. **설계사 알림을 끊지 마라.**

---

## 고칠 것

### ① 에러 삼킴 제거 (지금 당장의 위험)
```
src/lib/push-utils.ts  line 59·68·79·113  — 4곳 모두 await 만 하고 { error } 를 받지 않는다
```
실패해도 조용히 넘어가므로, **DDL 을 적용한 뒤 무언가 막혀도 아무도 모른다.**
4곳 전부 `{ error }` 를 받아 검사하고, 실패 시 로그를 남기고 `false` 를 반환하도록 고친다.
★ 사용자에게 오류 팝업을 띄우라는 뜻이 아니다. **조용한 실패를 없애라**는 뜻이다.

### ② customer 경로를 RPC 로 전환
`supabase.from('push_subscriptions')` 직접 쓰기(INSERT/UPDATE) 중 **고객 경로**를
`supabase.rpc('chat_register_push_subscription', {...})` 로 바꾼다.
RPC 는 `boolean` 을 돌려주므로 **false 를 실패로 처리**하라.

### ③ DDL 적용 — ★ 무중단 3단계 순서를 지켜라
한 번에 적용하면 프론트 배포 전까지 고객 경로가 막힌다. 반드시 쪼개라.
```
1단계  RPC 만 먼저 생성          (CREATE OR REPLACE FUNCTION …)  ← 기존 정책 유지, 무영향
2단계  프론트 PR 머지 + 배포     (ANU 가 수행)
3단계  anon INSERT/UPDATE/SELECT 정책 3건 DROP  ← 구멍 닫기
```
**봇은 1단계까지만 하고 멈춘다. 2·3단계는 ANU 가 한다.**
1단계 적용 전 `pg_policies` 전량을 덤프해 백업 파일로 남겨라.

접속: `psycopg2` + `aws-1-ap-northeast-2.pooler.supabase.com:5432` (session mode),
user `postgres.zayhfjuwviporbzokudr`, 비밀번호는 `/home/jay/projects/InsuRo/.env` 의 `SUPABASE_DB_PASSWORD`.
**비밀번호를 로그·보고서·커밋에 절대 남기지 마라.**

## 검증
1. **적용 전 기준선** — 위 ANU 프로브(anon INSERT with 타인 conversation_id)를 **직접 재현**하라.
   ★ 반드시 트랜잭션 + ROLLBACK 으로 하라. 프로덕션에 행을 남기지 마라.
2. **1단계 후** — RPC 가 생성됐고, 유효 토큰으로 호출 시 true, 위조/빈 토큰으로는 false 인지 확인.
   ★ PostgREST 는 RLS 차단 시 403 이 아니라 **빈 결과 + 200/204** 다. 응답코드로 판정하지 마라.
     **DB 에서 실제 행 변화를 직접 확인**하라.
3. **agent 경로 무손상** — 설계사 푸시 등록이 여전히 동작하는지 확인. 이것이 최우선 회귀 항목이다.
4. **에러 검사 실증** — 일부러 실패하는 조건을 만들어 `false` 가 반환되고 로그가 남는지 확인.
   (고치기 전 코드에서는 실패해도 `true` 로 보이던 것을 대조로 보여라)
5. **봉인 증명** — 새 테스트를 **변이시켜** 실제로 FAIL 하는지 확인하고 복원하라.
   "N passed" 재현만으로는 이빨을 증명하지 못한다.
6. `npm run build` EXIT=0 · `npx vitest run` 회귀 유지(**base 재측정 기준선**. 명세 수치 아님).

## 범위 · 금지
**수정 허용**: `src/lib/push-utils.ts` · `supabase/migrations/**`(1단계 분리 SQL 추가) · 관련 테스트
호출부(`PushNotificationToggle.tsx` · `CrmMessenger.tsx`)는 **시그니처가 바뀌는 경우에만** 최소 수정

**금지**:
- **anon 정책 3건 DROP 을 봇이 직접 실행** — 1단계(RPC 생성)까지만. 3단계는 ANU
- `authenticated` 용 정책 3건(`Agents can …`) 변경
- `conversation_messages` · `customer_chat_tokens` 정책(task-2991 결과물) 변경
- `src/components/navigation/**` — task-2998 결과물, 방금 배포됨
- `server/**` · `ops/**` · `.github/**`

## allowed_resources

```yaml
allowed_resources:
  paths:
    - "src/lib/push-utils.ts"
    - "src/components/PushNotificationToggle.tsx"
    - "src/pages/CrmMessenger.tsx"
    - "supabase/migrations/**"
    - "src/lib/__tests__/**"
    - "src/components/__tests__/**"
    - "tests/**"
    - "e2e/**"
    - "memory/reports/task-2999.md"
  forbidden_paths:
    - "src/components/navigation/**"
    - "src/components/AuthGuard.tsx"
    - "server/**"
    - "ops/**"
    - "scripts/**"
    - ".github/**"
  commands:
    - "npm run build"
    - "npx vitest run"
    - "npx playwright test"
    - "python3"
  merge_policy: "tiered"
  ttl_hours: 72
```

## 운영 계약
- `origin/main`(=`784b474`) 기준 `git pull --ff-only` 후 시작
- ★ `gh` 호출 시 `GH_TOKEN="$BOT_GITHUB_TOKEN"` 주입 필수 (회장 개인 PAT 금지 — 감사기록 오염)
- 워크플로우 `/home/jay/workspace/prompts/DIRECT-WORKFLOW.md` · QC `/home/jay/workspace/teams/shared/QC-RULES.md`
- `WORKSPACE_ROOT=/home/jay/workspace` · `CHAT_ID=6937032012` · 수집자 key `ANU_KEY=c119085addb0f8b7`
- 완료 경로는 `finish-task.sh` 실행이 유일하다. 수동 `.done` 금지.
- **명세와 실측이 다르면 임의 판단하지 말고 ANU 에 보고하라.**

## 완료 조건
에러검사 4곳 · customer RPC 전환 · agent 경로 무손상 실증 · 1단계 DDL 적용+백업 ·
취약점 재현→차단 대조 · 변이로 봉인 실증 · 빌드 0 · 회귀 유지

## 보고
**★★★ PR 생성까지가 범위다. 머지는 ANU 가 한다. 절대 직접 머지하지 마라.**
2026-08-22 에 봇 무단 머지가 **5건**(t2986·t2993·t2995·t2997·t2998) 발생했다.
원인은 `DIRECT-WORKFLOW.md` 의 "PASS → 자동 머지" 조항이었고 오늘 게이트를 넣어 고쳤다.
**태스크 md 의 머지 금지가 워크플로우보다 우선한다.** PR 을 열고 멈춰라.
`memory/reports/task-2999.md` 작성 후 표준 완료 콜백 등록. 콜백 프롬프트 **UTF-8 3900 bytes 이하**.

## goal_assertions (auto-generated)
- `npm run build`
- `npx vitest run`
