SkateSpots/push-service/README.md
SkateSpots 6407310213 feat(relay): cut over community relay to self-hosted Newlay
Point SKATE_RELAY, the default relay pools, the DM/CVM/zap relay arrays, the cvm-server + push-service defaults, and the archive default at wss://relay.skate-spots.org, keeping the legacy hosted relay as a fallback during the client-update window. Restored 115/115 archived events; the relay runs behind Caddy with TLS. Docs + AGENTS.md updated.
2026-10-12 12:38:13 +07:00

111 lines
5 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://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.