Adds the content-free activity cursor the Android APK can poll from a native
worker: POST /native/poll/register (NIP-98 -> poll token; the pubkey joins the
relay watch), GET /pending?token&since -> { seq, at, changed }, and
/native/poll/unregister. pushTo() bumps recordActivity(). Verified locally:
register -> offer addressed to the pubkey -> seq 0 -> 1, changed=true. No Google,
no decryption; the native plugin is the remaining piece.
5 KiB
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
-
A sender marks the recipient copy of a gift wrap with a plaintext tag
["skatespots", "push"](never the self copy). -
This service subscribes to
{ kinds: [1059] }on the configured relays, and for each wrap: verifies the outer signature, requires exactly oneprecipient, 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#pfilters 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. -
For a marked wrap it looks up that recipient's subscriptions and sends a Web Push payload:
{ title, body, wrapId, url }— content-free. -
The service worker shows an OS notification. On tap, the app fetches the wrap by
wrapIdand 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 withnode -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 (defaultskatespots).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://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 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 } |
registeris 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-pubkeyseqevery time something arrives — the same content-free signal as web push./pendingreturns only{ seq, at, changed }— never what arrived. A native worker polls it (standard= WorkManager ~15 min,fast= foreground service ~5 min), and whenseq > sinceit 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.