SkateSpots/push-service/README.md
SkateSpots 5f0a9537b0 feat(push): Android FCM (Phase 2) - native device support
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
2026-10-01 19:28:54 +07:00

111 lines
4.9 KiB
Markdown

# 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", <value>]` 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://goskatespots.nostr1.com 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 push (FCM, Android/iOS)
The same service also delivers **FCM** notifications to the native app, so
closed-app alerts work in the APK too. Setup (one-time, needs a Google account):
1. **Firebase project** → add an Android app with the package id
`org.skatespots.app`. Download **`google-services.json`** into
`android/app/` (gitignored — never commit it).
2. **Project settings → Service accounts → Generate new private key** → a
service-account JSON. Put it, single-line, in the server's `.env`:
```
PUSH_FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"…","client_email":"…","private_key":"…"}
```
3. Rebuild the APK (`npm run android:build`) and add the Gradle google-services
plugin (the Capacitor push plugin requires it).
Endpoints (native): `POST /native/subscribe` `{ token, platform, proof }` and
`POST /native/unsubscribe` `{ token, proof }` — same NIP-98 challenge proof as
web. `/health` reports `native: true|false` and the device count. The service
mints a Google OAuth2 token from the service account (cached ~1h) and posts to
`fcm.googleapis.com/v1/projects/<project_id>/messages:send`; an `UNREGISTERED`
token is dropped.
## 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.