Cloudwiki

Cloudwiki/설정/푸시알림

# VAPID 키 생성 및 Web Push 설정 가이드

CloudWiki 의 Web Push 알림 기능은 [VAPID (RFC 8292)](https://datatracker.ietf.org/doc/html/rfc8292) 표준을 따라 발신자 신원을 증명합니다. 이 문서는 운영자가 직접 VAPID 키 쌍을 생성하고 **Cloudflare 대시보드** 를 통해 Worker 에 등록하는 절차를 안내합니다.

> 본 문서는 CloudWiki 코드베이스가 사용하는 `@block65/webcrypto-web-push` 라이브러리의 키 형식 (`publicKey`: base64url 65바이트 P-256 uncompressed point, `privateKey`: base64url 32바이트 JWK `d` 스칼라) 을 기준으로 작성되었습니다.

---

## 키 형식 이해

VAPID 는 P-256 (secp256r1) ECDSA 키 쌍을 사용합니다. CloudWiki 가 기대하는 형식은 다음과 같습니다.

| 변수 | 형식 | 길이 (디코딩 후) | 대시보드 등록 타입 |
|---|---|---|---|
| `VAPID_PUBLIC_KEY` | base64url | 65 bytes — P-256 uncompressed point (`0x04` ‖ X(32) ‖ Y(32)) | **Plaintext** (공개) |
| `VAPID_PRIVATE_KEY` | base64url | 32 bytes — JWK `d` 필드 (P-256 private scalar) | **Secret** (암호화) |
| `VAPID_SUBJECT` | 문자열 | — `mailto:` 또는 `https://` URL | **Secret** (암호화 권장) |

`VAPID_PUBLIC_KEY` 는 클라이언트(브라우저)에 노출되어 `applicationServerKey` 로 사용되므로 비밀이 아닙니다. `VAPID_PRIVATE_KEY` 는 절대 외부에 유출되지 않도록 항상 Secret 으로 등록합니다.

---

## 키 생성 — 레포지토리의 `vapid.mjs` 사용

레포지토리에 포함된 `vapid.mjs` 스크립트를 실행하면 키를 즉시 생성할 수 있습니다.

```bash
node vapid.mjs
```

실행 결과 예시 (값은 실행할 때마다 다릅니다):

```text
VAPID_PUBLIC_KEY  = BNc...87자_근사_base64url_문자열...
VAPID_PRIVATE_KEY = aQ...43자_근사_base64url_문자열...
VAPID_SUBJECT     = mailto:admin@example.com
```

> 💡 출력된 두 키는 **반드시 한 쌍** 으로 사용해야 합니다. 둘을 따로 생성하면 푸시 서비스가 JWT 서명을 검증하지 못해 `401 Unauthorized` 가 반환됩니다.

---

## Cloudflare 에 등록

생성한 키를 등록하는 위치는 두 곳입니다.

- **공개키 (`VAPID_PUBLIC_KEY`)**: `wrangler.toml` 의 `[vars]` 섹션. **이쪽이 source of truth** 이며, 다음 배포가 일어날 때 대시보드의 동명 변수를 덮어쓰므로 *반드시 파일을 수정해 PR 로 커밋* 해야 합니다.
- **비밀키와 subject (`VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`)**: Cloudflare 대시보드의 Secret. Secret 은 배포에 의해 초기화되지 않으므로 대시보드에서 한 번 등록하면 됩니다.

### 공개키 — `wrangler.toml` 수정

`wrangler.toml` 의 `[vars]` 섹션에서 `VAPID_PUBLIC_KEY` 값을 새 공개키로 채운 뒤 PR 로 커밋합니다.

```toml
[vars]
# ... 기존 변수들 ...
VAPID_PUBLIC_KEY = "BNc...87자..."
```

> ⚠️ `wrangler.toml` 의 `[vars]` 가 비어 있는 채로 두고 대시보드에서만 공개키를 입력하면, 다음 자동 배포(예: main 브랜치 머지) 가 빈 문자열로 덮어써서 `/api/push/public-key` 가 갑자기 `enabled: false` 를 반환하게 됩니다. 공개키는 비밀이 아니므로 파일에 작성해도 무방합니다.

### 비밀키와 subject — 대시보드에 Secret 으로 등록

1. [Cloudflare 대시보드](https://dash.cloudflare.com/) 에 로그인.
2. 좌측 사이드바에서 **Workers & Pages** 선택 → 목록에서 `cloudwiki` Worker 클릭.
3. 상단 탭 **Settings** → 좌측 메뉴 **Variables and Secrets**.
4. **Add** 버튼을 눌러 두 항목을 등록합니다 (모두 **Secret / Encrypted** 타입).

   | Variable name | Type | Value |
   |---|---|---|
   | `VAPID_PRIVATE_KEY` | **Secret** | 위에서 생성한 비밀키 (예: `aQ...`) |
   | `VAPID_SUBJECT` | **Secret** | `mailto:admin@example.com` 또는 `https://wiki.example.com` 형식 |

5. 두 항목을 모두 추가한 뒤 **Save and deploy** 클릭. 배포가 끝나면 새 secret 이 즉시 활성화됩니다.

> 💡 secret 은 `wrangler.toml` 에 등장하지 않으며, 이후 어떤 `wrangler deploy` 에도 영향을 받지 않습니다. 한 번 등록하면 운영자가 직접 변경하기 전까지 유지됩니다.

### 등록 확인

브라우저에서 다음 URL 에 접속해 응답이 `enabled: true` 이고 `public_key` 값이 방금 등록한 공개키와 같은지 확인합니다.

```
https://<운영 도메인>/api/push/public-key
```

```json
{ "enabled": true, "public_key": "BNc...87자..." }
```

세 변수 중 하나라도 비어 있으면 `enabled: false` 를 반환하며 모든 푸시 발송 경로가 자동으로 비활성화됩니다.

---

## 동작 확인

1. 운영자 계정으로 로그인 → 헤더의 알림 종 아이콘 클릭 → 알림 패널 우측 상단의 **푸시 받기** 버튼 클릭.
2. 브라우저가 알림 권한을 요청하면 허용.
3. 다른 계정으로 쪽지를 보내거나, 토론 댓글 / 티켓 작성을 트리거.
4. 운영자 OS 의 알림 영역에 푸시가 도착하면 정상.

도착하지 않을 때 점검할 위치:

| 증상 | 점검 포인트 |
|---|---|
| 토글 버튼이 안 보임 | `/api/push/public-key` 가 `enabled: false` 반환 — 세 환경변수 중 하나가 비어 있음 |
| 토글을 눌러도 권한 다이얼로그가 안 뜸 | 브라우저 알림 권한이 이미 `denied` — 사이트 설정에서 초기화 필요 |
| 권한은 허용했는데 푸시가 안 옴 | 대시보드 → Worker → **Logs** (Real-time / Tail logs) 에서 `[push] push service error 401` 등을 확인. 키 쌍 불일치 가능성 |
| iOS Safari 에서 동작 안 함 | iOS 16.4+ 에서 **PWA 로 홈 화면에 추가된 경우에만** 푸시가 가능 |
| `signup_request` 거절/차단 푸시가 도착 안 함 | 신청자가 `setup-profile` 폼에서 옵트인을 켰는지, 그리고 신청 후 5분 이내에 처리되었는지 (단발성 토큰 TTL) |

---

## 키 회전

운영 중 키를 교체해야 하는 경우 주의: **공개키와 비밀키는 반드시 한 쌍** 이어야 하며, `src/utils/push.ts` 가 두 값을 함께 사용해 VAPID JWT 를 서명합니다. 한쪽만 먼저 적용되면 모든 푸시 발송이 401/403 으로 실패합니다. 회전은 다음 두 단계가 **같은 배포 시점** 에 활성화되도록 진행합니다.

- 공개키: `wrangler.toml` (source of truth) 수정 → PR 머지 → 자동 배포
- 비밀키: 대시보드의 Secret 갱신 → 즉시 적용

자동 배포 환경에서는 PR 머지 직후 곧바로 대시보드 Secret 을 갱신하면 mismatch 윈도우가 수 초 이내로 짧아지고, in-app 알림이 truth source 이므로 그 사이 발송 실패는 부가 채널 누락에 그칩니다. 무중단이 필요하면 점검 윈도우 안에서 회전합니다.

### 회전 절차

1. 새 키 쌍을 생성 (`node vapid.mjs`).
2. `wrangler.toml` 의 `VAPID_PUBLIC_KEY` 값을 새 공개키로 수정해 PR 을 만들고 머지. main 머지 시 Cloudflare Workers 자동 배포가 트리거됩니다.
3. 자동 배포 완료를 확인합니다 (대시보드 → **Workers & Pages** → `cloudwiki` → **Deployments** 에서 최신 빌드가 success 상태).
4. **곧바로** Cloudflare 대시보드 → **Workers & Pages** → `cloudwiki` → **Settings** → **Variables and Secrets** 로 이동.
5. `VAPID_PRIVATE_KEY` 항목의 **Edit** → 새 비밀키 붙여넣기 → **Save and deploy**. 이 시점부터 두 값이 모두 새 키로 활성화됩니다.
6. 회전 후 모든 기존 구독은 옛 공개키와 묶여 있어 그대로 두면 사용자가 다음 푸시를 받지 못합니다. 대시보드 → **Storage & Databases** → **D1** → `cloudwiki` → **Console** 에서 다음 SQL 을 실행해 정리하고 재구독을 안내합니다.
   ```sql
   DELETE FROM push_subscriptions;
   ```
7. 사용자는 알림 패널의 **푸시 끄기 → 푸시 받기** 를 다시 눌러 새 키로 재구독합니다.

> ⚠️ 절차 2 (PR 머지 후 자동 배포) 가 끝나기 전에 절차 5 (Secret 갱신) 를 먼저 누르면, 짧은 시간 동안 *옛 공개키 + 새 비밀키* 의 mismatch 상태가 됩니다. 반드시 Deployments 화면에서 새 공개키 배포가 success 인지 확인한 뒤 Secret 을 갱신하세요. 반대로 Secret 을 깜빡 잊으면 *새 공개키 + 옛 비밀키* mismatch 가 발생하므로, 두 단계를 짝으로 함께 수행해야 합니다.

### 무중단이 필요한 환경

발송 실패가 단 1건도 허용되지 않으면 사용자에게 사전 공지 (예: "20XX-XX-XX HH:MM 부터 약 1분간 푸시 알림이 일시 중단됩니다") 후 점검 윈도우 안에서 위 절차를 수행합니다. 그 시간에 발생한 알림은 in-app 으로만 누적되며, 회전 후 후속 알림부터 푸시가 정상화됩니다.

키 회전은 비밀키 유출 의심 또는 정기 보안 정책에 따라서만 수행하면 충분합니다.