# App API - Notify Service (Web Push)

Send real push notifications from a deployed app - to the browser, Android, and **iPhones added to the Home Screen** (iOS 16.4+). This is **Gipity Notify**: the platform owns the VAPID keys, does the RFC 8291 encryption + signing, delivers, and prunes dead subscriptions. App code never touches crypto or a key.

Reach for this before any third-party push service (OneSignal, Firebase Cloud Messaging, web-push libraries): it's first-party, needs no push-provider account or keys, and works from a function with one line.

**Subscribing requires a signed-in Gipity user - there is no anonymous subscription.** `/notify/subscribe` 401s without a session; subscriptions are keyed to the user (that's what `notify({ to })` targeting and pruning hang off). For a "no sign-in" ask, fold `Gipity.auth.signIn()` into the same Enable click - both prompts ride the one user activation (see the click rule below) - and tell the user a one-tap sign-in is the platform's floor for push.

## The shape

Two halves, both first-party:

1. **The `notify` kit** (browser) - a `<gipity-notify-button>` that subscribes the device, a service worker, and a PWA manifest. `gipity add notify`.
2. **The injected `notify()` service** (server) - declare `services: ['notify']` on a function and call `notify({ to, notification })`. The platform delivers.

Subscriptions are stored on the platform, keyed by your app's user guid (the same `ctx.auth.userGuid` you already use) - no table, no migration, no `gipity.yaml` permissions to manage.

## Quickest path: the starter

```bash
gipity add notify-demo && gipity deploy dev
```

A working app with a "turn on notifications" button and a "send a ping" button. Or start from the demo and strip it down.

## Add to an existing app

```bash
gipity add notify
```

Then two lines in your page:

```html
<link rel="manifest" href="manifest.webmanifest">          <!-- in <head>: enables iOS Home-Screen install -->
<script type="module">import '@gipity/notify';</script>     <!-- defines the button -->
<gipity-notify-button>Turn on notifications</gipity-notify-button>
```

The button signs the user in (push is per-user), asks permission, subscribes, and self-heals a stale subscription on every load. On iOS it shows an "Add to Home Screen first" hint until the app is installed (Apple only allows push for installed web apps).

## Send from a function

Declare the service in `gipity.yaml` and call the injected `notify()`:

```yaml
# gipity.yaml
- name: notify-event
  auth: user
  services: ['notify']
```

```js
// functions/notify-event/index.js
export default async function (ctx, { notify }) {
  const result = await notify({
    to: someUserGuid,                       // a user guid, an array of guids, or 'all'
    notification: {
      title: 'Game on!',
      body: '6pm pickleball at the courts',
      url: '/events/123',                   // opened when the notification is tapped
    },
  });
  return result; // { sent, failed, pruned, results }
}
```

- `to` uses the same user guid your app already has (`ctx.auth.userGuid`). Pass `'all'` to reach every subscriber.
- Dead subscriptions (expired/unsubscribed) are pruned automatically and reported in `pruned`.
- A send reaches **every device** the target user enabled.

## Billing

One flat credit per `notify()` send call (the **app owner** pays), regardless of how many devices it fans out to. A send that reaches zero devices is free. Out of credits returns a clear error - it never silently no-ops.

## Test & inspect from the CLI

```bash
gipity notify test --to all                 # push to every subscriber
gipity notify test --to <userGuid> --title "Hi" --body "..."
gipity notify subs                          # how many devices are subscribed, per user
gipity notify rm --user <userGuid>          # remove a user's subscriptions (e.g. on account deletion)
```

These run from anywhere — no `.gipity.json` needed. In a project directory they target that project; outside one they fall back to your Home project (one-off mode). Pass `--project <guid-or-slug>` to target a specific project explicitly.

**The headless inspector can't verify the *subscribe* path — don't try.** `gipity page inspect` / `page eval` drive **Chrome for Testing**, which ships without an FCM/GCM push service, so `PushManager.subscribe()` always fails there with `AbortError: Registration failed - permission denied` — even when the Notification permission is granted. You can confirm service-worker **registration** and **activation** headlessly, but the actual subscribe (and therefore end-to-end push) only works in a real browser on a real device. Verify the send path with `gipity notify test` against a device you subscribed by hand, and check `gipity notify subs` shows it. (This is also why there's no `--grant-notifications` inspector flag: granting the permission wouldn't make headless subscribe succeed.)

You manage subscriptions through these endpoints/commands rather than SQL (they live on the platform, not your app DB): a device manages its own via the button, and the owner can list (`subs`) and remove (`rm`, or `POST /services/notify/remove`). There's nothing to "edit" — a subscription is opaque device-generated data; you add or remove it.

## Frontend JS API (if you don't want the button)

```js
import { enableNotify, disableNotify, getNotifyState } from '@gipity/notify';

const state = await getNotifyState();   // 'enabled' | 'ready' | 'ios-needs-install' | 'denied' | 'unsupported'
if (state === 'ready') await enableNotify();
```

**Use the shipped `<gipity-notify-button>` / `enableNotify()` rather than hand-rolling the subscribe flow** — it already handles the gotchas below. If you do build your own UI, follow them:

- **Ask permission FIRST, synchronously in the click.** `Notification.requestPermission()` (and the sign-in popup) need the click's transient user activation. Any `await` before the prompt (a state read, `auth.user()`, a fetch) spends the activation and the browser **silently suppresses the prompt** — no prompt, no error, no subscription. Same rule `app-auth` documents for `signIn()`.
- **Don't use `navigator.serviceWorker.ready`.** Apps are served under a sub-path (`…/<account>/<app>/`), so the kit's SW registers with a sub-scope (`…/packages/notify/`) that does **not** control the page. `ready` only resolves for a page-controlling worker, so it hangs forever. Await the specific registration's worker reaching `activated` via a `statechange` listener instead.
- **Look the registration up by scope, not the no-arg `getRegistration()`.** The no-arg call returns the *page-controlling* registration — never the sub-scoped notify one — so a subscription read off it is always wrong/null. Use `getRegistrations().find(r => r.scope === ourScope)`.
- **In the SW's `notificationclick`, never `client.navigate()`.** A sub-scoped worker doesn't control the window, so `navigate()` throws. `client.focus()` an open tab and `postMessage` the URL (the page routes itself), or `clients.openWindow(url)` when nothing's open.
- **Ship SW updates:** the SW calls `skipWaiting()` + `clients.claim()`, and the page calls `registration.update()` on load — otherwise a fixed SW never reaches an installed device without a manual unregister.

## What gets delivered (wire contract)

`notify({ notification: { title, body, url, icon, tag } })` is delivered to the device as a push message whose JSON body is those fields **at the top level** — `{ "title", "body", "url", "icon", "tag" }` (not nested under `notification`). The kit's service worker reads `data.title` / `data.body` / `data.url` and stores `url` on `notification.data.url` for the click handler. `title` and `body` are required; `url` defaults to `/` on click. `gipity notify test` sends this same envelope, so test and production behave identically.

## Platform notes / gotchas

- **iOS** delivers push only to web apps **installed to the Home Screen** (Share -> Add to Home Screen), on **iOS 16.4+**. Desktop Chrome/Edge/Firefox and Android Chrome work in a normal browser tab. The kit detects this and guides the user - don't fight it.
- This is standard W3C Web Push (VAPID). **No Apple Developer account, no APNs certificates, no Firebase** - the platform handles all of it.
- No database is required. The `notify` kit works in any template (`web-simple`, `web-fullstack`, etc.).
- To send to a specific user, you need their app user guid - the value in `ctx.auth.userGuid` when they're signed in. The kit subscribes under that same guid.
- **Stale/foreign service workers self-heal - no manual unregister.** On the shared app hosts (`dev.gipity.ai` / `app.gipity.ai`) every app lives under one origin at `/<account>/<app>/`, so a leftover or mis-scoped worker from a *sibling* app could once control your page and poison its push (the symptom: a foreign `sw.js` throwing a `PushMessageData` JSON error in an unrelated app's console). The injected Gipity client SDK now unregisters any worker controlling the page from a scope broader than the app's own base path on every load, so you no longer need the DevTools "Unregister" dance to recover. Your own notify worker (sub-scoped `…/packages/notify/`) and custom/prod-domain apps are left untouched.

