# WebPush

Web Push notifications for Elixir. Payload encryption (RFC 8291 + RFC 8188 `aes128gcm`) and VAPID authentication (RFC 8292), sent over Finch.

Raw library: it starts no processes and holds no state. Your application supervises the Finch pool, stores the subscriptions, and decides what a notification looks like. WebPush handles the cryptography and the HTTP contract with the push service.

## Features

- **VAPID (RFC 8292)**: ES256 JWT signing with bare `:crypto`, no JWT dependency
- **Payload encryption (RFC 8291/8188)**: ECDH + HKDF + AES-128-GCM, verified byte-for-byte against the RFC 8291 §5 test vector
- **Zero state**: no application, no GenServers, no Ecto. Bring your own Finch pool and storage
- **Single dependency**: Finch (JSON encoding uses Elixir's native `JSON` module)

## Installation

```elixir
defp deps do
  [
    {:web_push, "~> 0.1.0"}
  ]
end
```

## Setup

Three moving parts: a Finch pool, VAPID keys, and stored subscriptions.

### 1. Supervise a Finch pool

```elixir
# lib/my_app/application.ex
children = [
  {Finch, name: MyApp.WebPushFinch}
]
```

```elixir
# config/config.exs
config :web_push, finch: MyApp.WebPushFinch
```

### 2. Generate VAPID keys

```
mix web_push.gen.vapid
```

```elixir
# config/runtime.exs
config :web_push, :vapid,
  public_key: System.fetch_env!("VAPID_PUBLIC_KEY"),
  private_key: System.fetch_env!("VAPID_PRIVATE_KEY"),
  subject: "mailto:you@example.com"
```

The public key is not secret: the browser needs it to subscribe (see below). The private key signs every push request.

### 3. Store subscriptions

Browsers hand you a JSON object per subscribed device:

```json
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/...",
  "keys": {"p256dh": "B...", "auth": "..."}
}
```

Persist it however you like (an Ecto table with a unique index on `endpoint` works well). When sending, map the row into a `WebPush.Subscription`:

```elixir
%WebPush.Subscription{endpoint: row.endpoint, p256dh: row.p256dh, auth: row.auth}
```

For request bodies straight from the browser, `WebPush.Subscription.from_map/1` parses the nested JSON shape.

## Sending

```elixir
alias WebPush.Subscription

sub = %Subscription{endpoint: endpoint, p256dh: p256dh, auth: auth}

case WebPush.send(sub, %{"title" => "new order", "url" => "/admin/orders/1"}, urgency: "high") do
  :ok -> :sent
  {:error, :gone} -> MyApp.Notifications.delete_subscription(sub.endpoint)
  {:error, reason} -> Logger.warning("push failed: #{inspect(reason)}")
end
```

Options:

| option | default | meaning |
|---|---|---|
| `:ttl` | `86400` | seconds the push service may hold the message |
| `:urgency` | `"normal"` | `"very-low"`, `"low"`, `"normal"`, `"high"` |
| `:topic` | none | collapse key; pending messages with the same topic are replaced |
| `:finch` | configured | per-call Finch pool override |

Return values:

- `:ok`: the push service accepted the message (200/201/202/204)
- `{:error, :gone}`: 404 or 410, the subscription is dead. Delete it from storage
- `{:error, term}`: transport failure or unexpected status

## Client side

WebPush is server-side only. The browser half is two small files, adapted here as a starting point. Yours will differ in routes, CSRF handling, and copy.

### Service worker (`priv/static/sw.js`)

```js
self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || "notification", {
      body: data.body || "",
      tag: data.tag,
      data: { url: data.url || "/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const url = (event.notification.data && event.notification.data.url) || "/";
  event.waitUntil(self.clients.openWindow(url));
});
```

### Subscribing

```js
// Your server exposes the VAPID public key (WebPush.Vapid.public_key/0)
// at some endpoint, e.g. GET /push/vapid-key.
async function subscribe() {
  const reg = await navigator.serviceWorker.ready;
  const { public_key } = await (await fetch("/push/vapid-key")).json();

  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(public_key),
  });

  // POST sub.toJSON() to your server and persist it.
  await fetch("/push/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(sub.toJSON()),
  });
}

function urlBase64ToUint8Array(b64) {
  const padding = "=".repeat((4 - (b64.length % 4)) % 4);
  const raw = atob((b64 + padding).replace(/-/g, "+").replace(/_/g, "/"));
  return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
```

Note that push requires a service worker registration and a user gesture for the permission prompt on most browsers.

## How it fits together

```
payload map
  -> JSON.encode!/1                     (WebPush)
  -> RFC 8291 encryption                (WebPush.Encryption, pure :crypto)
  -> VAPID Authorization header         (WebPush.Vapid, pure :crypto)
  -> POST to subscription endpoint      (Finch, your pool)
```

`WebPush.Encryption` and `WebPush.Vapid` are pure functions and can be used on their own if you need a different transport.

## License

MIT. See [LICENSE](LICENSE).
