SkateSpots/push-service
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
..
.env.example feat(relay): cut over community relay to self-hosted Newlay 2026-10-12 12:38:13 +07:00
Caddyfile feat(push): self-hosted closed-app push service (Phase 1 backend) 2026-10-01 15:34:43 +07:00
package.json feat(push): self-hosted closed-app push service (Phase 1 backend) 2026-10-01 15:34:43 +07:00
README.md feat(relay): cut over community relay to self-hosted Newlay 2026-10-12 12:38:13 +07:00
server.ts feat(relay): cut over community relay to self-hosted Newlay 2026-10-12 12:38:13 +07:00
skatespots-push.service feat(push): self-hosted closed-app push service (Phase 1 backend) 2026-10-01 15:34:43 +07:00
tsconfig.json feat(push): self-hosted closed-app push service (Phase 1 backend) 2026-10-01 15:34:43 +07:00

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

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.