# LidGuard.Notifications

🌐 [English](README.md)

## 개요

`LidGuard.Notifications`는 LidGuard pre-suspend 및 post-session-end webhook을 받아 구독된 브라우저로 Web Push 알림을 전달하는 작은 ASP.NET Core Razor Pages 서버입니다. 브라우저 구독, webhook event, delivery 결과는 SQLite에 저장합니다.

이 서버는 의도적으로 순수 WebAssembly 앱이 아닙니다. LidGuard는 들어오는 webhook을 받을 HTTP endpoint가 필요하고, Web Push에는 서버에만 보관해야 하는 private VAPID key가 필요합니다.

## HTTPS와 localhost 요구 사항

브라우저 Web Push는 secure context가 필요합니다. 운영 환경에서는 HTTPS를 사용하세요. 개발 중에는 브라우저가 `localhost`에서도 service worker와 push API를 허용합니다.

HTTPS, 강력한 `AccessToken`, 별도의 강력한 `WebhookSecret` 없이 이 서버를 공개하지 마세요.

## AccessToken과 WebhookSecret 생성

`AccessToken`과 `WebhookSecret`은 서로 다른 랜덤 값으로 생성합니다. `AccessToken`은 브라우저 dashboard 로그인에 사용됩니다. `WebhookSecret`은 LidGuard webhook URL에 포함되므로 URL-safe 값을 사용합니다.

OS에 맞는 명령을 두 번 실행해서 하나는 `AccessToken`, 하나는 `WebhookSecret`으로 사용합니다:

```powershell
# Windows PowerShell
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
```

```bash
# Linux
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
```

```bash
# macOS
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
```

두 값은 모두 Git 밖에 보관합니다. 두 설정에 같은 값을 재사용하지 마세요.

## VAPID key 생성

임시 .NET project를 만들지 말고 CLI에서 VAPID key를 생성합니다. `web-push` npm package는 cross-platform generator를 제공하며 이 저장소에 dependency를 추가하지 않습니다.

먼저 OS에 맞게 `npx`를 준비합니다:

- Windows: 공식 installer로 Node.js LTS를 설치하거나 `winget install OpenJS.NodeJS.LTS`를 실행합니다.
- macOS: 공식 installer로 Node.js LTS를 설치하거나 `brew install node`를 실행합니다.
- Linux: 배포판 package로 Node.js와 npm을 설치합니다. Debian/Ubuntu에서는 `sudo apt install nodejs npm`을 사용할 수 있습니다.

그다음 모든 OS에서 같은 명령을 실행합니다:

```bash
npx --yes web-push generate-vapid-keys
```

생성된 public key는 `VapidPublicKey`에, private key는 `VapidPrivateKey`에 넣습니다.

Private key는 절대 client JavaScript, service worker, 브라우저에서 볼 수 있는 파일, Git-tracked configuration에 넣지 마세요. 개발 환경에서는 `dotnet user-secrets`를 사용하고, 운영 환경에서는 환경 변수나 hosting provider의 secret store를 사용하세요.

## User-Secrets 설정

저장소 루트에서 실행합니다:

```powershell
dotnet user-secrets set "LidGuardNotifications:AccessToken" "<personal-access-token>" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:WebhookSecret" "<webhook-secret>" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:VapidPublicKey" "<vapid-public-key>" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:VapidPrivateKey" "<vapid-private-key>" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:VapidSubject" "mailto:you@example.com" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:PublicBaseUrl" "https://localhost:5001" --project LidGuard.Notifications
dotnet user-secrets set "LidGuardNotifications:UserInterfaceCulture" "auto" --project LidGuard.Notifications
```

`AccessToken`과 `WebhookSecret`은 서로 다른 값이어야 합니다.

`DatabasePath`는 선택 사항입니다. 생략하면 서버는 Windows에서 `%LOCALAPPDATA%\LidGuard\Notifications\notifications.sqlite`를 사용합니다.

Dashboard 접근에는 15분짜리 짧은 인증 쿠키를 사용합니다. `로그인 상태 기억`을 체크하면 서버가 해시된 refresh token을 SQLite에 저장하고 refresh cookie를 14일 동안 유지합니다. 인증된 페이지 요청마다 refresh token을 회전하고 짧은 인증 쿠키를 갱신합니다.

`UserInterfaceCulture`는 선택 사항이며 기본값은 `auto`입니다. `en`, `ko`, 또는 `CultureInfo`로 해석 가능한 culture name을 받습니다. `LIDGUARD_UI_CULTURE` 환경 변수가 이 값을 덮어씁니다.

## 환경 변수 설정

중첩 설정에는 double underscore를 사용합니다:

```powershell
$env:LidGuardNotifications__AccessToken = "<personal-access-token>"
$env:LidGuardNotifications__WebhookSecret = "<webhook-secret>"
$env:LidGuardNotifications__VapidPublicKey = "<vapid-public-key>"
$env:LidGuardNotifications__VapidPrivateKey = "<vapid-private-key>"
$env:LidGuardNotifications__VapidSubject = "mailto:you@example.com"
$env:LidGuardNotifications__PublicBaseUrl = "https://notify.example.com"
$env:LidGuardNotifications__DatabasePath = "C:\Data\LidGuard\notifications.sqlite"
$env:LidGuardNotifications__UserInterfaceCulture = "auto"
$env:LIDGUARD_UI_CULTURE = "ko"
```

## 로컬 실행

```powershell
dotnet run --project LidGuard.Notifications
```

표시된 localhost URL을 열고 `AccessToken`으로 로그인하면서 `로그인 상태 기억`을 유지하거나 해제한 뒤 dashboard에서 브라우저를 구독합니다.

## 브라우저 구독과 구독 해제

1. HTTPS 또는 `localhost`로 dashboard를 엽니다.
2. `AccessToken`으로 로그인합니다.
3. `Subscribe browser`를 선택합니다.
4. 브라우저 알림을 허용합니다.
5. 현재 브라우저 구독을 제거하려면 `Unsubscribe`를 사용합니다.

Subscription은 endpoint 기준으로 upsert됩니다. 새 브라우저 구독은 저장된 row를 다시 활성화하고 endpoint key를 갱신합니다.

## LidGuard webhook URL

서버 URL과 webhook secret으로 LidGuard를 설정합니다:

```powershell
lidguard settings --pre-suspend-webhook-url https://host/api/webhooks/lidguard/{webhookSecret}
lidguard settings --post-session-end-webhook-url https://host/api/webhooks/lidguard/{webhookSecret}
lidguard settings --closed-lid-stop-follow-up-webhook-url https://host/api/webhooks/lidguard/{webhookSecret}
```

`{webhookSecret}`을 설정된 `WebhookSecret`으로 바꿉니다. Webhook secret은 LidGuard 전용입니다. 브라우저 로그인용 `AccessToken`과 같은 값이면 안 됩니다.

## 테스트용 webhook 호출

브라우저를 하나 이상 구독한 뒤 `curl` 또는 PowerShell을 사용합니다:

```powershell
curl.exe -X POST "https://host/api/webhooks/lidguard/{webhookSecret}" -H "Content-Type: application/json" -d "{\"eventType\":\"PreSuspend\",\"reason\":\"SoftLocked\",\"userInterfaceCulture\":\"ko\",\"softLockedSessionCount\":2}"
curl.exe -X POST "https://host/api/webhooks/lidguard/{webhookSecret}" -H "Content-Type: application/json" -d "{\"eventType\":\"PreSuspend\",\"reason\":\"Completed\",\"userInterfaceCulture\":\"ko\",\"provider\":\"Codex\",\"sessionIdentifier\":\"abc123\",\"startedAtUtc\":\"2026-05-02T00:00:00Z\",\"lastActivityAtUtc\":\"2026-05-02T00:03:00Z\",\"endedAtUtc\":\"2026-05-02T00:04:00Z\",\"endReason\":\"SessionEnd\",\"activeSessionCount\":0,\"inputPromptPreview\":\"Summarize the latest changes\",\"lastResponse\":\"Updated the webhook payload and notification UI before the configured suspend flow.\"}"
curl.exe -X POST "https://host/api/webhooks/lidguard/{webhookSecret}" -H "Content-Type: application/json" -d "{\"eventType\":\"PostSessionEnd\",\"reason\":\"SessionEnded\",\"userInterfaceCulture\":\"en\",\"provider\":\"Codex\",\"sessionIdentifier\":\"abc123\",\"startedAtUtc\":\"2026-05-02T00:00:00Z\",\"lastActivityAtUtc\":\"2026-05-02T00:03:00Z\",\"endedAtUtc\":\"2026-05-02T00:04:00Z\",\"endReason\":\"SessionEnd\",\"activeSessionCount\":0,\"inputPromptPreview\":\"Summarize the latest changes\",\"lastResponse\":\"Updated the webhook payload and notification UI. The event list shows a short preview, and clicking the row details shows the full response.\"}"
curl.exe -X POST "https://host/api/webhooks/lidguard/{webhookSecret}" -H "Content-Type: application/json" -d "{\"eventType\":\"StopFollowUp\",\"reason\":\"AwaitingReply\",\"userInterfaceCulture\":\"ko\",\"provider\":\"Codex\",\"sessionIdentifier\":\"abc123\",\"startedAtUtc\":\"2026-05-02T00:00:00Z\",\"lastActivityAtUtc\":\"2026-05-02T00:03:00Z\",\"endedAtUtc\":\"2026-05-02T00:04:00Z\",\"endReason\":\"Stop\",\"replyWaitSeconds\":10,\"replyDeadlineUtc\":\"2026-05-02T00:04:10Z\",\"lastResponse\":\"Need your reply before I suspend.\"}"
```

Webhook endpoint는 event를 기록하고 즉시 `202 Accepted`를 반환합니다. `StopFollowUp`일 때는 `followUpRequestIdentifier`, `replyPollUrl`, `expiresAtUtc`가 담긴 JSON 본문도 함께 반환합니다. 이후 background service가 알림을 보내고 delivery 결과를 기록합니다.

인증된 dashboard에서는 `/events`에서 pending follow-up 카드에 답장할 수 있고, API로는 `POST /api/follow-ups/{publicIdentifier}/reply`에 `{ "reply": "..." }` JSON을 보낼 수 있습니다. 답장 없이 바로 Stop/절전 흐름을 진행하려면 `/events`에서 대기를 취소하거나 `POST /api/follow-ups/{publicIdentifier}/cancel`을 호출합니다. 다음 poll은 `Canceled`를 반환하므로 LidGuard가 즉시 기존 Stop 및 절전 흐름으로 내려갑니다. LidGuard runtime은 인증 없이 `GET /api/follow-ups/{publicIdentifier}/poll/{pollToken}`를 polling하므로, poll token은 일시적인 비밀 값처럼 다뤄야 합니다. 서버는 SQLite에 poll token hash만 저장합니다.

## 운영 체크리스트

- 앱을 HTTPS로 제공합니다.
- `AccessToken`, `WebhookSecret`, `VapidPrivateKey`는 Git 밖에 저장합니다.
- `AccessToken`과 `WebhookSecret`은 서로 다르게 유지합니다.
- `VapidSubject`는 연락 가능한 `mailto:` 또는 HTTPS URL로 설정합니다.
- 알림 이력이 중요하다면 SQLite database를 백업합니다.
- 가능하면 dashboard로 들어오는 접근을 제한합니다.
- `/events` 페이지 또는 `WebhookEvents`, `NotificationDeliveries` 테이블로 delivery 실패를 모니터링합니다.

## 문제 해결

- 브라우저 구독이 되지 않으면 페이지가 HTTPS 또는 `localhost`로 제공되는지 확인합니다.
- 브라우저가 알림 권한을 묻지 않으면 browser settings에서 해당 site의 알림이 허용되어 있는지 확인합니다.
- `Subscribe browser`가 VAPID 오류로 실패하면 key를 다시 생성하고 server configuration의 public key가 private key와 맞는지 확인합니다.
- LidGuard webhook이 `404`를 반환하면 URL에 정확한 `WebhookSecret`이 들어 있는지 확인합니다.
- Event는 보이지만 알림이 오지 않으면 `/events`를 열어 permanent 또는 transient delivery failure를 확인합니다.
- HTTP `404` 또는 `410`을 동반한 permanent Web Push 실패는 subscription을 비활성화합니다. 브라우저를 다시 구독하세요.
