SkateSpots/push-service/README.md
SkateSpots 807037ee9b feat(push): self-hosted closed-app push service (Phase 1 backend)
Our own keyless Bun service (not Linky's): watches relays for kind-1059 wraps
carrying the plaintext marker ["skatespots","push"] on the recipient copy,
extracts only {recipient, wrapId}, and sends a Web Push. NEVER decrypts - the
client fetches + decrypts the named wrap itself.

Endpoints: GET /health, GET /vapid-public-key, POST /auth/challenge ->
/subscribe|/unsubscribe with a one-time NIP-98 (kind 27235) challenge proof.
State is a JSON file; VAPID keys from env. Wired like the other standalone
servers (typecheck:push + check:push in npm test, deploy:push, Caddyfile +
systemd unit). Verified locally end-to-end: auth -> subscribe -> marked wrap ->
watcher -> push attempt, and proof replay rejected.

Client half (service worker, subscribe hook + Settings toggle, marker on DM
sends) is the next step.
2026-10-01 15:34:43 +07:00

81 lines
3.3 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.
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`.
## 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.