# SkateSpots push service A small, **keyless** Bun service that delivers **closed-app notifications** for NIP-17 gift wraps. It watches relays for kind-1059 wraps that carry a plaintext push marker, and sends a Web Push naming the wrap id. It **never decrypts anything** — the client fetches + decrypts the wrap itself. So the server learns only *"this pubkey has something, at time T."* ## How it works 1. A sender marks the **recipient copy** of a gift wrap with a plaintext tag `["skatespots", "push"]` (never the self copy). 2. This service subscribes to `{ kinds: [1059] }` on the configured relays, and for each wrap: verifies the **outer** signature, requires exactly one `p` recipient, and checks the marker. It ALSO pushes **marketplace events** addressed to subscribers — offers (`30403`, the whole negotiation), trade requests (`31872`), confirmations (`31871`) and shipment status (`34967`: shipped / received / issue) — via `#p` filters built from the current subscription set (so it only receives events for subscribers; the payload is generic, never the amount or item). Re-subscribed on every subscribe/unsubscribe. 3. For a marked wrap it looks up that recipient's subscriptions and sends a Web Push payload: `{ title, body, wrapId, url }` — **content-free**. 4. The service worker shows an OS notification. On tap, the app fetches the wrap by `wrapId` and decrypts locally, revealing the real message. ⚠️ **Relay overlap:** the marked wrap must land on a relay this service watches. Designate a **notify relay** (the community relay) and publish push-marked recipient copies there. ## Endpoints | Method | Path | Body | Returns | | --- | --- | --- | --- | | GET | `/health` | — | `{ ok, subscriptions, relays }` | | GET | `/vapid-public-key` | — | `{ key }` | | POST | `/auth/challenge` | `{ pubkey }` | `{ challenge, expiresAt }` | | POST | `/subscribe` | `{ subscription, proof }` | `{ ok }` | | POST | `/unsubscribe` | `{ endpoint, proof }` | `{ ok }` | `subscription` is the standard `PushSubscription.toJSON()` shape: `{ endpoint, keys: { p256dh, auth } }`. `proof` is a **NIP-98** kind-27235 event signed by the subscribing pubkey, carrying `["challenge", ]` from `/auth/challenge`. A challenge is **one-time** (consumed on use) and expires after `PUSH_CHALLENGE_TTL_SECONDS`. ## Environment See `.env.example`. Key ones: - `PUSH_VAPID_PUBLIC_KEY` / `PUSH_VAPID_PRIVATE_KEY` — Web Push VAPID keys. Generate with `node -e "console.log(require('web-push').generateVAPIDKeys())"`. **Changing them invalidates all subscriptions.** - `PUSH_RELAYS` — comma-separated relays to watch. - `PUSH_MARKER` — the marker tag name (default `skatespots`). - `PUSH_STORAGE_PATH` — JSON state file. - `PUSH_ALLOWED_ORIGINS` — CORS allow-list for `/subscribe`. ## Local dev ```bash cd push-service bun install PORT=8788 PUSH_RELAYS=wss://relay.skate-spots.org bun run server.ts # typecheck (from repo root): npx tsc -p push-service/tsconfig.json ``` Without `PUSH_VAPID_*` it generates **ephemeral** keys (fine for a smoke test; subscriptions break on restart). ## Deploy `npm run deploy:push` (reads host/SSH from `~/.config/skatespots/cvm.env`, never committed). It uploads the source, generates VAPID keys **on the host** into `.env`, installs the systemd unit, and restarts. Public TLS is served by Caddy — copy `Caddyfile` to `/etc/caddy/Caddyfile` and reload once. Logs: `npm run deploy:push -- --logs`. ## Native background polling (no Google) — the Cordn pattern For the Android **APK** (a Capacitor WebView has **no Web Push**), the service supports a **content-free activity cursor** the app polls from a native background worker — no Google, no distributor app. | Method | Path | Body | Returns | | --- | --- | --- | --- | | POST | `/native/poll/register` | `{ pubkey, proof }` | `{ token, seq }` | | GET | `/pending?token=&since=` | — | `{ seq, at, changed }` | | POST | `/native/poll/unregister` | `{ token, proof }` | `{ ok }` | - `register` is guarded by the same NIP-98 challenge proof; it returns an **unguessable poll token**. The server then **watches the relays for that pubkey** (added to the market subscription) and bumps a per-pubkey `seq` every time something arrives — **the same content-free signal as web push**. - `/pending` returns only `{ seq, at, changed }` — never *what* arrived. A native worker polls it (`standard` = WorkManager ~15 min, `fast` = foreground service ~5 min), and when `seq > since` it shows a **local notification** and advances its cursor. - **No decryption, no keys, no Google** on the server. Cordn does the same thing with an extra Rust/MLS layer because they decrypt previews; we don't need to. ## Privacy The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and sees only timing + recipient. Content, sender and amounts stay in the encrypted wrap. `/privacy` discloses this. Web Push delivery routes through the browser vendor's push service (Chrome→Google, Firefox→Mozilla); Android uses FCM.