{
  "name": "app-notify",
  "title": "App API - Notify Service (Web Push)",
  "description": "Web push notifications for deployed apps (Gipity Notify): the notify kit, the injected notify() service, iOS home-screen support - platform owns VAPID keys + delivery, no crypto",
  "guid": "sk_plat_anfy",
  "category": "App services",
  "requiredTools": [],
  "content": "# App API - Notify Service (Web Push)\n\nSend 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.\n\nReach 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.\n\n**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.\n\n## The shape\n\nTwo halves, both first-party:\n\n1. **The `notify` kit** (browser) - a `<gipity-notify-button>` that subscribes the device, a service worker, and a PWA manifest. `gipity add notify`.\n2. **The injected `notify()` service** (server) - declare `services: ['notify']` on a function and call `notify({ to, notification })`. The platform delivers.\n\nSubscriptions 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.\n\n## Quickest path: the starter\n\n```bash\ngipity add notify-demo && gipity deploy dev\n```\n\nA working app with a \"turn on notifications\" button and a \"send a ping\" button. Or start from the demo and strip it down.\n\n## Add to an existing app\n\n```bash\ngipity add notify\n```\n\nThen two lines in your page:\n\n```html\n<link rel=\"manifest\" href=\"manifest.webmanifest\">          <!-- in <head>: enables iOS Home-Screen install -->\n<script type=\"module\">import '@gipity/notify';</script>     <!-- defines the button -->\n<gipity-notify-button>Turn on notifications</gipity-notify-button>\n```\n\nThe 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).\n\n## Send from a function\n\nDeclare the service in `gipity.yaml` and call the injected `notify()`:\n\n```yaml\n# gipity.yaml\n- name: notify-event\n  auth: user\n  services: ['notify']\n```\n\n```js\n// functions/notify-event/index.js\nexport default async function (ctx, { notify }) {\n  const result = await notify({\n    to: someUserGuid,                       // a user guid, an array of guids, or 'all'\n    notification: {\n      title: 'Game on!',\n      body: '6pm pickleball at the courts',\n      url: '/events/123',                   // opened when the notification is tapped\n    },\n  });\n  return result; // { sent, failed, pruned, results }\n}\n```\n\n- `to` uses the same user guid your app already has (`ctx.auth.userGuid`). Pass `'all'` to reach every subscriber.\n- Dead subscriptions (expired/unsubscribed) are pruned automatically and reported in `pruned`.\n- A send reaches **every device** the target user enabled.\n\n## Billing\n\nOne 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.\n\n## Test & inspect from the CLI\n\n```bash\ngipity notify test --to all                 # push to every subscriber\ngipity notify test --to <userGuid> --title \"Hi\" --body \"...\"\ngipity notify subs                          # how many devices are subscribed, per user\ngipity notify rm --user <userGuid>          # remove a user's subscriptions (e.g. on account deletion)\n```\n\nThese 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.\n\n**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.)\n\nYou 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.\n\n## Frontend JS API (if you don't want the button)\n\n```js\nimport { enableNotify, disableNotify, getNotifyState } from '@gipity/notify';\n\nconst state = await getNotifyState();   // 'enabled' | 'ready' | 'ios-needs-install' | 'denied' | 'unsupported'\nif (state === 'ready') await enableNotify();\n```\n\n**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:\n\n- **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()`.\n- **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.\n- **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)`.\n- **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.\n- **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.\n\n## What gets delivered (wire contract)\n\n`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.\n\n## Platform notes / gotchas\n\n- **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.\n- This is standard W3C Web Push (VAPID). **No Apple Developer account, no APNs certificates, no Firebase** - the platform handles all of it.\n- No database is required. The `notify` kit works in any template (`web-simple`, `web-fullstack`, etc.).\n- 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.\n- **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.\n"
}
