# webpush

> Send Web Push notifications to browsers with VAPID keys, and build and verify the signed tokens that push services require.

This is the Markdown version of https://osl-lang.org/packages/webpush. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:webpush"
```

Web Push lets a server show a notification in a user's browser even when your site is closed. The browser gives your page a subscription (an address plus keys), you send it to your server, and `std:webpush` encrypts a message for it and delivers it through the browser vendor's push service.

Sending needs a real subscription from a real browser, so `send` is shown but not run. Everything around it, the keys and the signed tokens, runs fine here.

## Make your server's keys

A push service wants to know which server is sending. You prove it with a VAPID key pair. `generateKeys()` makes one: keep `privateKey` secret, and give `publicKey` to the page so it can subscribe:

```osl
import "std:webpush"

auto keys = webpush.generateKeys()

log keys.privateKey.startsWith("-----BEGIN")
log keys.publicKey.len
log webpush.derivePublicKey(keys.privateKey).unwrap() == keys.publicKey
```

Output:

```text
true
87
true
```

The public key is derived from the private one, so the private key is all you have to keep.

## Keep keys between runs

Making new keys on every start would break existing subscriptions. `ensureKeys(path)` loads the key stored at `path`, or creates and saves one the first time:

```osl
import "std:webpush"
import "std:fs"

fs.remove("vapid.pem", {force: true}).unwrap()

auto first = webpush.ensureKeys("vapid.pem").unwrap()
auto second = webpush.ensureKeys("vapid.pem").unwrap()

log fs.exists("vapid.pem")
log first.publicKey == second.publicKey

fs.remove("vapid.pem", {force: true}).unwrap()
```

Output:

```text
true
true
```

Run it twice: the second run loads what the first saved. Treat that file as a secret.

## Sign and verify a token

Each push request carries a short-lived token (an ES256 JSON Web Token) for the push service's address. `signJWT(audience, privateKey, options)` makes one. A `subject` contact address is required, so the service can reach you if something goes wrong:

```osl
import "std:webpush"

auto keys = webpush.generateKeys()
string token = webpush.signJWT(
  "https://push.example.com",
  keys.privateKey,
  {subject: "mailto:admin@example.com"}
).unwrap()

log token.split(".").len
log webpush.verifyJWT(token, keys.publicKey)
log webpush.verifyJWT(token, webpush.generateKeys().publicKey)
```

Output:

```text
3
true
false
```

`send` does this for you. You only call `signJWT` yourself when you talk to a push service by other means.

## Check a subscription

The browser's subscription has an `endpoint` URL and two keys, `p256dh` and `auth`. `verifySubscription` says whether they are the right shape, so you can refuse junk at the door before you store it:

```osl
import "std:webpush"

log webpush.verifySubscription("http://push.example.com/abc", "key", "secret")
log webpush.verifySubscription("not a url", "", "")
```

Output:

```text
false
false
```

Try an `https` endpoint with real keys from a browser to see `true`. A plain `http` endpoint is always refused.

## Send a notification

With a subscription from a browser, `send(subscription, payload, options)` encrypts the payload (up to 3993 bytes) and delivers it. An object payload goes as JSON, which your service worker reads in its `push` event:

```osl
import "std:webpush"
import "std:env"

auto subscription = {
  endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
  keys: {p256dh: "BNc...", auth: "tBH..."}
}

auto outcome = webpush.send(subscription, {title: "New message", body: "Ada says hi"}, {
  privateKey: env.get("VAPID_PRIVATE_KEY"),
  subject: "mailto:admin@example.com",
  ttl: 3600000
})

switch outcome (
  case ok(_)
    log "delivered to the push service"
  case err(e)
    log "failed:" e.kind
)
```

A `NotFoundError` usually means the user unsubscribed, so delete that subscription from your store. `ttl` is how long the push service should hold the message when the device is offline, in milliseconds.

> **Warning**
>
> The endpoint comes from a browser, so anyone can submit one. `send` only posts to public `https` addresses and refuses loopback and private ones unless you set `allowPrivate`. Leave that off unless you run your own push server.

**Quiz.** Why does `send` need your private VAPID key?

- [ ] To decrypt the user's reply.
- [x] To sign a token that proves to the push service which server is sending.
- [ ] To encrypt the payload.

Answer: The payload is encrypted for the subscription's own keys. The VAPID key only identifies you.

### Exercise: Fresh keys, same identity

Make a key pair, then print whether `derivePublicKey` of the private key matches the public one, and whether a token signed with it verifies.

Starting code:

```osl
import "std:webpush"

auto keys = webpush.generateKeys()

log false
log false
```

It should print:

```text
true
true
```

Hint 1: `webpush.derivePublicKey(keys.privateKey).unwrap() == keys.publicKey`.

Hint 2: Sign with `webpush.signJWT("https://push.example.com", keys.privateKey, {subject: "mailto:a@example.com"}).unwrap()` and pass the token to `verifyJWT`.

A solution:

```osl
import "std:webpush"

auto keys = webpush.generateKeys()

log webpush.derivePublicKey(keys.privateKey).unwrap() == keys.publicKey

string token = webpush.signJWT(
  "https://push.example.com",
  keys.privateKey,
  {subject: "mailto:a@example.com"}
).unwrap()
log webpush.verifyJWT(token, keys.publicKey)
```

## API reference

## API reference

Every member of `std:webpush`, generated from the source of OSL.

### Functions

#### `webpush.derivePublicKey(string privateKey) result<string, error>`

derivePublicKey returns the base64url VAPID public key that belongs to a
PEM-encoded P-256 private key. Fails with ParseError when privateKey isn't
one.

#### `webpush.ensureKeys(string path) result<{privateKey: string, publicKey: string}, error>`

ensureKeys returns the VAPID key pair whose private key is stored at
path, creating the key and saving it there (readable only by the owner)
when the file doesn't exist yet. Use it to keep the same keys across
restarts, since browsers' subscriptions are tied to the public key. Fails
with ParseError when the file isn't a PEM-encoded P-256 private key (it is
never overwritten), and PermissionError or IOError when it can't be read
or written.

#### `webpush.ensureVAPIDKeys(any configPath, any privateKeyPath) object`

**Deprecated.** Deprecated: use webpush.ensureKeys(path) instead, which keeps everything in the private key file and never overwrites a file it can't read.

ensureVAPIDKeys(configPath, privateKeyPath) loads the keys saved by an
earlier call, or generates new ones, writes the private key and a
{public_key, claims_email} config (claims_email is the package author's
address, as before), and returns the config, or {error} on failure.
Malformed files are replaced.

#### `webpush.generateKeys() {privateKey: string, publicKey: string}`

generateKeys creates a new VAPID key pair: privateKey is a PEM-encoded
P-256 key to keep secret on the server, and publicKey is the base64url
key to give browsers as applicationServerKey when they subscribe.

#### `webpush.generateVAPIDKeys() object`

**Deprecated.** Deprecated: use webpush.generateKeys() instead, which returns {privateKey, publicKey}.

generateVAPIDKeys returns {private_key, public_key}, or {error} when no
key could be generated.

#### `webpush.send(object subscription, any payload, object? options) result<unit, error>`

send encrypts payload (at most 3993 bytes) and delivers it to a browser
subscription. Options: privateKey and subject (required), ttl
(milliseconds, default 86400000), urgency, topic, timeout and
allowPrivate. The endpoint comes from the browser, so send only goes
where a push service can be: an https URL whose host resolves to a
public address, without following redirects. allowPrivate (default
false) also allows loopback, private and link-local addresses, for a
push service on your own network such as a self-hosted UnifiedPush
server; it lets any client make the server post to those addresses.
Fails with NotFoundError, ValueError (also for an endpoint that isn't
https or resolves to an address that isn't allowed), PermissionError,
NetworkError, TimeoutError or RuntimeError.

#### `webpush.sendWebPush(any endpoint, any p256dh, any auth, any ...rest) object`

**Deprecated.** Deprecated: use webpush.send(subscription, payload, {privateKey, subject, ttl}) instead, which takes ttl in milliseconds and fails with a typed error.

sendWebPush(endpoint, p256dh, auth, data, vapidPrivateKey,
vapidClaimsEmail, ttl) sends one message with ttl in seconds and returns
{success, status, error}.

#### `webpush.signJWT(string audience, string privateKey, object? options) result<string, error>`

signJWT returns a VAPID token (an ES256 JSON Web Token) for audience, the
origin of a push service such as "https://fcm.googleapis.com". send makes
these itself; signJWT is for code that talks to a push service directly.
Options: subject (required, a "mailto:" or "https:" contact the push
service can reach you at) and expires (milliseconds the token stays
valid, default 43200000, at most 86400000). Fails with ParseError when
privateKey isn't a PEM-encoded P-256 private key. Throws ValueError for a
missing subject or an expires out of range.

#### `webpush.signVapidJWT(any audience, any expiresIn, any privateKeyPEM, any ...claimsEmail) string`

**Deprecated.** Deprecated: use webpush.signJWT(audience, privateKey, {subject, expires}) instead, which takes expires in milliseconds.

signVapidJWT(audience, expiresIn, privateKeyPEM, claimsEmail) signs a
VAPID token valid for expiresIn seconds (default and maximum 86400), or
returns "" when the key is invalid.

#### `webpush.verifyJWT(string token, string publicKey) boolean`

verifyJWT reports whether token is a VAPID token signed by the private
key that belongs to publicKey (base64url) and has not expired.

#### `webpush.verifySubscription(string endpoint, string p256dh, string auth) boolean`

verifySubscription reports whether endpoint is an https URL, p256dh
a base64url P-256 public key and auth a base64url 16-byte secret: the
parts of a browser's PushSubscription that send needs.

#### `webpush.verifyVapidJWT(any token, any publicKey) boolean`

**Deprecated.** Deprecated: use webpush.verifyJWT(token, publicKey) instead, which also rejects expired tokens.

verifyVapidJWT reports whether token's signature matches publicKey,
without checking its expiry.
